@slicedearth/whoisleuth-cli 2.2.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -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 +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  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 +112 -72
  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-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -2,111 +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
- '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);
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";
102
14
  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);
15
+ const DECISION_CONFIDENCE_LEVELS = new Set(CASE_DECISION_CONFIDENCE_LEVELS);
110
16
  const ASSERTION_KINDS = new Set(CASE_ASSERTION_KINDS);
111
17
  const ASSERTION_STATES = new Set(CASE_ASSERTION_STATES);
112
18
  const EVIDENCE_RELATION_STANCES = new Set(CASE_EVIDENCE_RELATION_STANCES);
@@ -116,71 +22,6 @@ const TRAIL_KINDS = new Set(CASE_MANUAL_TRAIL_KINDS);
116
22
  const SIGHTING_STATES = new Set(CASE_SIGHTING_STATES);
117
23
  const SIGHTING_CATEGORIES = new Set(CASE_SIGHTING_CATEGORIES);
118
24
  const SHA256_RE = /^[a-f0-9]{64}$/u;
119
- function currentActionNormalizationOptions(validEvidencePinIds) {
120
- return validEvidencePinIds
121
- ? { sourceVersion: CASE_SCHEMA_VERSION, validEvidencePinIds }
122
- : { sourceVersion: CASE_SCHEMA_VERSION };
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
25
  function sourceSchema(value) {
185
26
  const item = record(value);
186
27
  const collection = text(item.collection, 80);
@@ -241,19 +82,6 @@ function certificateObservation(value, pin, options = {}) {
241
82
  namesComplete: item.namesComplete,
242
83
  };
243
84
  }
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
85
  function normalizeEvidenceRelations(value, legacyIds, validIds) {
258
86
  const output = new Map();
259
87
  if (Array.isArray(value)) {
@@ -299,7 +127,7 @@ function normalizePin(raw, fallback, options = {}) {
299
127
  source: text(item.source, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_selected',
300
128
  sourceState: text(item.sourceState, 40) || null,
301
129
  sourceSchema: normalizedSourceSchema,
302
- observedAt: iso(item.observedAt, createdAt, options),
130
+ observedAt: optionalIso(item.observedAt, options),
303
131
  collectionDepth: item.collectionDepth === 'deep' || item.collectionDepth === 'fast'
304
132
  ? item.collectionDepth
305
133
  : 'unknown',
@@ -321,6 +149,24 @@ function normalizePin(raw, fallback, options = {}) {
321
149
  value,
322
150
  sourceSchema: normalizedSourceSchema,
323
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
+ }
324
170
  return normalized;
325
171
  }
326
172
  export function normalizeCaseEvidencePins(raw, fallback, options = {}) {
@@ -337,6 +183,9 @@ export function normalizeCaseEvidencePins(raw, fallback, options = {}) {
337
183
  .slice(-MAX_CASE_EVIDENCE_PINS);
338
184
  }
339
185
  export function appendCaseEvidencePin(current, raw, now) {
186
+ if (current.length >= MAX_CASE_EVIDENCE_PINS) {
187
+ throw new Error(`A Case can retain at most ${MAX_CASE_EVIDENCE_PINS} evidence pins. No existing evidence was removed.`);
188
+ }
340
189
  const item = record(raw);
341
190
  const created = normalizePin({ ...item, id: freshId('pin'), createdAt: now }, now);
342
191
  if (!created)
@@ -363,10 +212,17 @@ function normalizeDecision(raw, fallback, validPinIds, options = {}) {
363
212
  if (!summary || !rationale)
364
213
  return null;
365
214
  const createdAt = iso(item.createdAt, fallback, options);
215
+ const currentConfidence = typeof item.confidence === 'string' && DECISION_CONFIDENCE_LEVELS.has(item.confidence)
216
+ ? item.confidence
217
+ : 'unknown';
366
218
  return {
367
219
  id: safeId(item.id, 'decision', { summary, rationale, createdAt }),
368
220
  summary,
369
221
  rationale,
222
+ confidence: options.sourceVersion != null && options.sourceVersion < 15 ? 'unknown' : currentConfidence,
223
+ confidenceBasis: options.sourceVersion != null && options.sourceVersion < 15
224
+ ? ''
225
+ : text(item.confidenceBasis, MAX_RESPONSE_RATIONALE_LENGTH),
370
226
  evidencePinIds: uniqueIds(item.evidencePinIds, validPinIds),
371
227
  createdAt,
372
228
  };
@@ -385,638 +241,21 @@ export function normalizeCaseDecisions(raw, fallback, validPinIds, options = {})
385
241
  .slice(-MAX_CASE_DECISIONS);
386
242
  }
387
243
  export function appendCaseDecision(current, raw, now, validPinIds) {
244
+ if (current.length >= MAX_CASE_DECISIONS) {
245
+ throw new Error(`A Case can retain at most ${MAX_CASE_DECISIONS} decisions. No existing decision was removed.`);
246
+ }
388
247
  const item = record(raw);
389
248
  const created = normalizeDecision({ ...item, id: freshId('decision'), createdAt: now }, now, validPinIds);
390
249
  if (!created)
391
250
  throw new Error('A decision requires a summary and rationale.');
392
251
  return normalizeCaseDecisions([...current, created], now, validPinIds);
393
252
  }
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
- routeObservedAt: options.sourceVersion != null && options.sourceVersion < 14
672
- ? null
673
- : optionalIso(item.routeObservedAt, options),
674
- contactLimitations: limitations(item.contactLimitations),
675
- dueAt: optionalIso(item.dueAt, options),
676
- state: history.state,
677
- reference: latestReference,
678
- followUpAt: optionalIso(item.followUpAt, options),
679
- providerOutcome: latestProviderOutcome?.providerOutcome ?? null,
680
- outcome: latestProviderOutcome?.outcomeDetail ?? latestOutcomeDetail?.outcomeDetail ?? null,
681
- originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
682
- ? item.originActionId
683
- : null,
684
- history: history.history,
685
- historyOmitted: history.omitted,
686
- historyLimitations: history.historyLimitations,
687
- createdAt,
688
- metadataUpdatedAt,
689
- updatedAt,
690
- };
691
- }
692
- function mergeNormalizedActions(left, right, fallback) {
693
- const metadataWinner = Date.parse(left.metadataUpdatedAt) > Date.parse(right.metadataUpdatedAt)
694
- ? left
695
- : Date.parse(right.metadataUpdatedAt) > Date.parse(left.metadataUpdatedAt)
696
- ? right
697
- : JSON.stringify(left) <= JSON.stringify(right) ? left : right;
698
- return normalizeAction({
699
- ...metadataWinner,
700
- id: left.id,
701
- createdAt: Date.parse(left.createdAt) <= Date.parse(right.createdAt) ? left.createdAt : right.createdAt,
702
- history: [...left.history, ...right.history],
703
- historyOmitted: Math.max(left.historyOmitted, right.historyOmitted),
704
- historyLimitations: [...left.historyLimitations, ...right.historyLimitations],
705
- }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
706
- }
707
- function boundActionCollection(actions, fallback) {
708
- const keep = new Set();
709
- for (const action of actions) {
710
- const latest = action.history.at(-1);
711
- if (latest)
712
- keep.add(`${action.id}\u0000${latest.id}`);
713
- }
714
- const all = actions.flatMap((action) => action.history.map((event) => ({ action, event })))
715
- .sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt)
716
- || compareCodeUnits(right.event.id, left.event.id));
717
- for (const item of all) {
718
- if (keep.size >= MAX_CASE_ACTION_EVENTS_PER_CASE)
719
- break;
720
- keep.add(`${item.action.id}\u0000${item.event.id}`);
721
- }
722
- let bounded = actions.map((action) => {
723
- const history = action.history.filter((event) => keep.has(`${action.id}\u0000${event.id}`));
724
- const omitted = action.historyOmitted + action.history.length - history.length;
725
- return normalizeAction({ ...action, history, historyOmitted: omitted }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
726
- });
727
- while (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE) {
728
- const candidate = bounded
729
- .flatMap((action) => action.history.slice(0, -1).map((event) => ({ action, event })))
730
- .sort((left, right) => Date.parse(left.event.occurredAt) - Date.parse(right.event.occurredAt)
731
- || compareCodeUnits(left.event.id, right.event.id))[0];
732
- if (!candidate)
733
- break;
734
- bounded = bounded.map((action) => action.id !== candidate.action.id
735
- ? action
736
- : normalizeAction({
737
- ...action,
738
- history: action.history.filter((event) => event.id !== candidate.event.id),
739
- historyOmitted: action.historyOmitted + 1,
740
- }, fallback, { sourceVersion: CASE_SCHEMA_VERSION }));
741
- }
742
- if (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE && bounded.length > 1) {
743
- const byteOmissionPattern = /^(\d+) earlier response actions? omitted by the per-Case response-history byte bound\.$/u;
744
- const previouslyOmitted = bounded.reduce((maximum, action) => {
745
- const retainedCount = action.historyLimitations.reduce((count, item) => {
746
- const match = byteOmissionPattern.exec(item);
747
- return match ? Math.max(count, Number(match[1])) : count;
748
- }, 0);
749
- return Math.max(maximum, retainedCount);
750
- }, 0);
751
- bounded = bounded.map((action) => ({
752
- ...action,
753
- historyLimitations: action.historyLimitations.filter((item) => !byteOmissionPattern.test(item)),
754
- }));
755
- let omittedActions = 0;
756
- while (bounded.length > 1) {
757
- bounded = bounded.slice(1);
758
- omittedActions += 1;
759
- const first = bounded[0];
760
- const totalOmitted = previouslyOmitted + omittedActions;
761
- bounded = [{
762
- ...first,
763
- historyLimitations: lifecycleLimitations([
764
- `${totalOmitted} earlier response action${totalOmitted === 1 ? '' : 's'} omitted by the per-Case response-history byte bound.`,
765
- ...first.historyLimitations,
766
- ]),
767
- }, ...bounded.slice(1)];
768
- if (bytes(bounded) <= MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE)
769
- return bounded;
770
- }
771
- }
772
- return bounded;
773
- }
774
- export function normalizeCaseActions(raw, fallback, options = {}) {
775
- if (!Array.isArray(raw))
776
- return [];
777
- const byId = new Map();
778
- const inspected = raw.slice(0, MAX_CASE_ACTIONS * 2);
779
- let invalid = 0;
780
- for (const item of inspected) {
781
- const normalized = normalizeAction(item, fallback, options);
782
- if (!normalized) {
783
- invalid += 1;
784
- continue;
785
- }
786
- const existing = byId.get(normalized.id);
787
- byId.set(normalized.id, existing ? mergeNormalizedActions(existing, normalized, fallback) : normalized);
788
- }
789
- const normalizedActions = [...byId.values()]
790
- .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id));
791
- const uninspected = Math.max(0, raw.length - inspected.length);
792
- const omitted = Math.max(0, normalizedActions.length - MAX_CASE_ACTIONS);
793
- const actions = normalizedActions.slice(-MAX_CASE_ACTIONS);
794
- if (actions.length && (invalid || uninspected || omitted)) {
795
- const first = actions[0];
796
- actions[0] = {
797
- ...first,
798
- historyLimitations: lifecycleLimitations([
799
- ...(invalid ? [`${invalid} malformed response-action candidate${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
800
- ...(uninspected ? [`${uninspected} response-action candidate${uninspected === 1 ? '' : 's'} beyond the bounded inspection window were not traversed.`] : []),
801
- ...(omitted ? [`${omitted} earlier response action${omitted === 1 ? '' : 's'} omitted by the per-Case action bound.`] : []),
802
- ...first.historyLimitations,
803
- ]),
804
- };
805
- }
806
- const actionIds = new Set(actions.map((action) => action.id));
807
- const linked = actions.map((action) => {
808
- const originActionId = action.originActionId && actionIds.has(action.originActionId) && action.originActionId !== action.id
809
- ? action.originActionId
810
- : null;
811
- const invalidOrigins = action.history.filter((event) => event.originActionId
812
- && (!actionIds.has(event.originActionId) || event.originActionId === action.id)).length;
813
- return {
814
- ...action,
815
- originActionId,
816
- history: action.history.map((event) => ({
817
- ...event,
818
- originActionId: event.originActionId && actionIds.has(event.originActionId) && event.originActionId !== action.id
819
- ? event.originActionId
820
- : null,
821
- })),
822
- historyLimitations: lifecycleLimitations([
823
- ...action.historyLimitations,
824
- ...(invalidOrigins || (action.originActionId && !originActionId)
825
- ? ['One or more dangling or self-referential origin-action links were omitted.']
826
- : []),
827
- ]),
828
- };
829
- });
830
- return boundActionCollection(linked, fallback);
831
- }
832
- export function appendCaseAction(current, raw, now) {
833
- if (current.length >= MAX_CASE_ACTIONS) {
834
- throw new Error(`A Case can retain at most ${MAX_CASE_ACTIONS} response actions. No additional action was retained.`);
835
- }
836
- const item = record(raw);
837
- const id = freshId('action');
838
- const originActionId = typeof item.originActionId === 'string' && current.some((action) => action.id === item.originActionId)
839
- ? item.originActionId
840
- : null;
841
- if (item.originActionId != null && !originActionId)
842
- throw new Error('A follow-on action requires an existing originating action.');
843
- const history = [{
844
- id: freshId('action-event'),
845
- previousState: null,
846
- nextState: 'drafting',
847
- occurredAt: now,
848
- sourceClass: 'analyst',
849
- provenance: 'browser_local_action_creation',
850
- reference: null,
851
- evidencePinId: null,
852
- limitations: [],
853
- providerOutcome: null,
854
- outcomeDetail: null,
855
- originActionId,
856
- }];
857
- const created = normalizeAction({
858
- ...item,
859
- id,
860
- originActionId,
861
- history,
862
- historyOmitted: 0,
863
- createdAt: now,
864
- metadataUpdatedAt: now,
865
- }, now, { sourceVersion: CASE_SCHEMA_VERSION });
866
- if (!created)
867
- throw new Error('An action requires a recipient or internal owner.');
868
- return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
869
- }
870
- export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
871
- const action = current.find((item) => item.id === actionId);
872
- if (!action)
873
- throw new Error('That case action no longer exists.');
874
- const item = record(raw);
875
- const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
876
- ? item.nextState
877
- : null;
878
- const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
879
- ? item.sourceClass
880
- : 'analyst';
881
- if (!nextState || !isLegalCaseActionTransition(action.state, nextState, sourceClass)) {
882
- throw new Error(`The transition from ${action.state.replaceAll('_', ' ')} to ${String(item.nextState || 'that state').replaceAll('_', ' ')} is not permitted.`);
883
- }
884
- if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState) && sourceClass !== 'analyst') {
885
- throw new Error('Readiness, review, authorisation, and submission require an explicit analyst transition.');
886
- }
887
- const originActionId = typeof item.originActionId === 'string'
888
- && item.originActionId !== action.id
889
- && current.some((candidate) => candidate.id === item.originActionId)
890
- ? item.originActionId
891
- : action.originActionId;
892
- if (item.originActionId != null && originActionId !== item.originActionId) {
893
- throw new Error('A referral or follow-on transition requires a distinct existing originating action.');
894
- }
895
- if (item.evidencePinId != null && (typeof item.evidencePinId !== 'string'
896
- || !SAFE_ID_RE.test(item.evidencePinId) || (validPinIds && !validPinIds.has(item.evidencePinId)))) {
897
- throw new Error('An action transition evidence pin must reference a retained Case evidence pin.');
898
- }
899
- const occurredAt = optionalIso(item.occurredAt) ?? now;
900
- const event = normalizeActionEvent({
901
- ...item,
902
- id: freshId('action-event'),
903
- previousState: action.state,
904
- nextState,
905
- occurredAt,
906
- sourceClass,
907
- provenance: text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || 'browser_local_explicit_transition',
908
- originActionId,
909
- }, action.id, currentActionNormalizationOptions(validPinIds));
910
- if (!event)
911
- throw new Error('The action transition contains an invalid time, provider outcome, or provenance field.');
912
- const updated = normalizeAction({
913
- ...action,
914
- history: [...action.history, event],
915
- historyOmitted: action.historyOmitted,
916
- }, now, currentActionNormalizationOptions(validPinIds));
917
- return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
918
- }
919
- export function updateCaseAction(current, raw, now, validPinIds) {
920
- const patch = record(raw);
921
- const id = typeof patch.id === 'string' && SAFE_ID_RE.test(patch.id) ? patch.id : '';
922
- const existing = current.find((item) => item.id === id);
923
- if (!existing)
924
- throw new Error('That case action no longer exists.');
925
- if (Object.hasOwn(patch, 'state') || Object.hasOwn(patch, 'outcome') || Object.hasOwn(patch, 'providerOutcome') || Object.hasOwn(patch, 'reference')) {
926
- throw new Error('State, reference, and provider outcomes must be recorded as an append-only action transition.');
927
- }
928
- const metadata = {
929
- ...existing,
930
- type: Object.hasOwn(patch, 'type') ? patch.type : existing.type,
931
- recipient: Object.hasOwn(patch, 'recipient') ? patch.recipient : existing.recipient,
932
- contactSource: Object.hasOwn(patch, 'contactSource') ? patch.contactSource : existing.contactSource,
933
- routeObservedAt: Object.hasOwn(patch, 'routeObservedAt') ? patch.routeObservedAt : existing.routeObservedAt,
934
- contactLimitations: Object.hasOwn(patch, 'contactLimitations') ? patch.contactLimitations : existing.contactLimitations,
935
- dueAt: Object.hasOwn(patch, 'dueAt') ? patch.dueAt : existing.dueAt,
936
- followUpAt: Object.hasOwn(patch, 'followUpAt') ? patch.followUpAt : existing.followUpAt,
937
- originActionId: Object.hasOwn(patch, 'originActionId') ? patch.originActionId : existing.originActionId,
938
- metadataUpdatedAt: now,
939
- };
940
- if (Object.hasOwn(patch, 'originActionId') && patch.originActionId !== null
941
- && (typeof patch.originActionId !== 'string' || patch.originActionId === id || !current.some((item) => item.id === patch.originActionId))) {
942
- throw new Error('A follow-on action requires a distinct existing originating action.');
943
- }
944
- let updated = normalizeAction(metadata, now, currentActionNormalizationOptions(validPinIds));
945
- if (!updated)
946
- throw new Error('An action requires a recipient or internal owner.');
947
- const materialChanged = ['type', 'recipient', 'contactSource', 'routeObservedAt', 'contactLimitations', 'originActionId']
948
- .some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
949
- if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
950
- throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
951
- }
952
- if (materialChanged && ['ready_for_review', 'reviewed', 'authorised'].includes(existing.state)) {
953
- const invalidated = normalizeAction({
954
- ...updated,
955
- history: [...updated.history, {
956
- id: freshId('action-event'),
957
- previousState: existing.state,
958
- nextState: 'drafting',
959
- occurredAt: now,
960
- sourceClass: 'browser_local',
961
- provenance: 'material_action_change',
962
- reference: null,
963
- evidencePinId: null,
964
- limitations: ['Material action inputs changed after review; prior readiness, review, or authorisation no longer applies.'],
965
- providerOutcome: null,
966
- outcomeDetail: null,
967
- originActionId: updated.originActionId,
968
- }],
969
- }, now, currentActionNormalizationOptions(validPinIds));
970
- if (!invalidated)
971
- throw new Error('The material action change could not be retained safely.');
972
- updated = invalidated;
973
- }
974
- const interim = current.map((item) => item.id === id ? updated : item);
975
- return patch.transition !== undefined
976
- ? appendCaseActionTransition(interim, id, patch.transition, now, validPinIds)
977
- : normalizeCaseActions(interim, now, currentActionNormalizationOptions(validPinIds));
978
- }
979
- export function buildCaseActionOutcomeSummary(actions, nowRaw = new Date().toISOString()) {
980
- const now = optionalIso(nowRaw) ?? new Date().toISOString();
981
- const nowTime = Date.parse(now);
982
- const active = actions.filter((item) => item.state !== 'terminal');
983
- const latestOutcomes = actions
984
- .flatMap((action) => action.history.filter((event) => event.applied && event.providerOutcome).map((event) => ({ action, event })))
985
- .sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt) || compareCodeUnits(right.event.id, left.event.id))
986
- .slice(0, 5)
987
- .map(({ action, event }) => ({
988
- actionId: action.id,
989
- recipient: action.recipient,
990
- state: action.state,
991
- providerOutcome: event.providerOutcome,
992
- outcomeDetail: event.outcomeDetail,
993
- occurredAt: event.occurredAt,
994
- }));
995
- return {
996
- total: actions.length,
997
- active: active.length,
998
- drafting: actions.filter((item) => item.state === 'drafting').length,
999
- readyForReview: actions.filter((item) => item.state === 'ready_for_review').length,
1000
- reviewed: actions.filter((item) => item.state === 'reviewed').length,
1001
- authorised: actions.filter((item) => item.state === 'authorised').length,
1002
- submitted: actions.filter((item) => item.state === 'submitted').length,
1003
- acknowledged: actions.filter((item) => item.state === 'acknowledged').length,
1004
- terminal: actions.filter((item) => item.state === 'terminal').length,
1005
- overdue: active.filter((item) => item.dueAt && Date.parse(item.dueAt) < nowTime).length,
1006
- followUpDue: active.filter((item) => item.followUpAt && Date.parse(item.followUpAt) <= nowTime).length,
1007
- withProviderOutcome: actions.filter((item) => item.providerOutcome !== null).length,
1008
- latestOutcomes,
1009
- };
1010
- }
1011
253
  export function mergeCaseEvidencePins(local, imported, fallback) {
1012
254
  return normalizeCaseEvidencePins([...local, ...imported], fallback);
1013
255
  }
1014
256
  export function mergeCaseDecisions(local, imported, fallback, validPinIds) {
1015
257
  return normalizeCaseDecisions([...local, ...imported], fallback, validPinIds);
1016
258
  }
1017
- export function mergeCaseActions(local, imported, fallback, validPinIds) {
1018
- return normalizeCaseActions([...local, ...imported], fallback, currentActionNormalizationOptions(validPinIds));
1019
- }
1020
259
  function assertionProvenanceList(value, maximum) {
1021
260
  if (!Array.isArray(value))
1022
261
  return [];
@@ -1077,6 +316,10 @@ function normalizeAssertion(raw, fallback, validPinIds, options = {}) {
1077
316
  const provenance = normalizeAssertionProvenance(item.provenance, options);
1078
317
  const legacyIds = uniqueIds(item.evidencePinIds, validPinIds);
1079
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
+ }
1080
323
  return {
1081
324
  id: safeId(item.id, 'assertion', { statement, createdAt }),
1082
325
  kind: typeof item.kind === 'string' && ASSERTION_KINDS.has(item.kind)
@@ -1092,6 +335,7 @@ function normalizeAssertion(raw, fallback, validPinIds, options = {}) {
1092
335
  createdAt,
1093
336
  updatedAt: iso(item.updatedAt, createdAt, options),
1094
337
  ...(provenance ? { provenance } : {}),
338
+ ...(recheck ? { recheck } : {}),
1095
339
  };
1096
340
  }
1097
341
  export function normalizeCaseAssertions(raw, fallback, validPinIds, options = {}) {
@@ -1112,6 +356,9 @@ export function normalizeCaseAssertions(raw, fallback, validPinIds, options = {}
1112
356
  .slice(-MAX_CASE_ASSERTIONS);
1113
357
  }
1114
358
  export function appendCaseAssertion(current, raw, now, validPinIds) {
359
+ if (current.length >= MAX_CASE_ASSERTIONS) {
360
+ throw new Error(`A Case can retain at most ${MAX_CASE_ASSERTIONS} assertions. No existing assertion was removed.`);
361
+ }
1115
362
  const item = record(raw);
1116
363
  const created = normalizeAssertion({
1117
364
  ...item,
@@ -1129,6 +376,8 @@ export function updateCaseAssertion(current, raw, now, validPinIds) {
1129
376
  const existing = current.find((item) => item.id === id);
1130
377
  if (!existing)
1131
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.');
1132
381
  const updated = normalizeAssertion({
1133
382
  ...existing,
1134
383
  ...patch,
@@ -1170,6 +419,9 @@ export function normalizeCaseManualTrail(raw, fallback, options = {}) {
1170
419
  .slice(-MAX_CASE_MANUAL_TRAIL_EVENTS);
1171
420
  }
1172
421
  export function appendCaseManualTrailEvent(current, raw, now) {
422
+ if (current.length >= MAX_CASE_MANUAL_TRAIL_EVENTS) {
423
+ throw new Error(`A Case can retain at most ${MAX_CASE_MANUAL_TRAIL_EVENTS} investigation-trail entries. No existing entry was removed.`);
424
+ }
1173
425
  const item = record(raw);
1174
426
  const created = normalizeManualTrailEvent({ ...item, id: freshId('trail'), createdAt: now }, now);
1175
427
  if (!created)
@@ -1211,7 +463,7 @@ function normalizeCaseSighting(raw, fallback, validPinIds, options = {}) {
1211
463
  ? item.category
1212
464
  : 'other',
1213
465
  source,
1214
- observedAt: iso(item.observedAt, createdAt, options),
466
+ observedAt: optionalIso(item.observedAt, options),
1215
467
  completeness: typeof item.completeness === 'string' && COMPLETENESS.has(item.completeness)
1216
468
  ? item.completeness
1217
469
  : 'unknown',
@@ -1230,10 +482,13 @@ export function normalizeCaseSightings(raw, fallback, validPinIds, options = {})
1230
482
  byId.set(normalized.id, normalized);
1231
483
  }
1232
484
  return [...byId.values()]
1233
- .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))
1234
486
  .slice(-MAX_CASE_SIGHTINGS);
1235
487
  }
1236
488
  export function appendCaseSighting(current, raw, now, validPinIds) {
489
+ if (current.length >= MAX_CASE_SIGHTINGS) {
490
+ throw new Error(`A Case can retain at most ${MAX_CASE_SIGHTINGS} sightings. No existing sighting was removed.`);
491
+ }
1237
492
  const item = record(raw);
1238
493
  const created = normalizeCaseSighting({
1239
494
  ...item,
@@ -1247,312 +502,6 @@ export function appendCaseSighting(current, raw, now, validPinIds) {
1247
502
  export function mergeCaseSightings(local, imported, fallback, validPinIds) {
1248
503
  return normalizeCaseSightings([...local, ...imported], fallback, validPinIds);
1249
504
  }
1250
- function normalizeObservedEffectReview(raw, fallback, validPinIds, validSightingIds, options = {}) {
1251
- const item = record(raw);
1252
- if (typeof item.state !== 'string' || !OBSERVED_EFFECT_STATES.has(item.state))
1253
- return null;
1254
- if (typeof item.sourceClass !== 'string' || !OBSERVED_EFFECT_SOURCE_CLASSES.has(item.sourceClass))
1255
- return null;
1256
- const source = text(item.source, MAX_RESPONSE_LABEL_LENGTH);
1257
- const observedAt = optionalIso(item.observedAt, options);
1258
- if (!source || !observedAt)
1259
- return null;
1260
- const createdAt = iso(item.createdAt, observedAt || fallback, options);
1261
- const evidencePinId = typeof item.evidencePinId === 'string'
1262
- && SAFE_ID_RE.test(item.evidencePinId)
1263
- && (!validPinIds || validPinIds.has(item.evidencePinId))
1264
- ? item.evidencePinId
1265
- : null;
1266
- const sightingId = typeof item.sightingId === 'string'
1267
- && SAFE_ID_RE.test(item.sightingId)
1268
- && (!validSightingIds || validSightingIds.has(item.sightingId))
1269
- ? item.sightingId
1270
- : null;
1271
- const linkLimitations = [
1272
- ...(item.evidencePinId != null && !evidencePinId ? ['A malformed or dangling evidence-pin reference was omitted from this independent review.'] : []),
1273
- ...(item.sightingId != null && !sightingId ? ['A malformed or dangling sighting reference was omitted from this independent review.'] : []),
1274
- ];
1275
- return {
1276
- id: safeId(item.id, 'effect-review', { state: item.state, source, observedAt, createdAt }),
1277
- state: item.state,
1278
- observedAt,
1279
- sourceClass: item.sourceClass,
1280
- source,
1281
- completeness: typeof item.completeness === 'string' && COMPLETENESS.has(item.completeness)
1282
- ? item.completeness
1283
- : 'unknown',
1284
- limitations: limitations([...linkLimitations, ...limitations(item.limitations)]),
1285
- evidencePinId,
1286
- sightingId,
1287
- followUpAt: optionalIso(item.followUpAt, options),
1288
- createdAt,
1289
- };
1290
- }
1291
- export function normalizeCaseObservedEffectHistory(raw, fallback, validPinIds, validSightingIds, options = {}) {
1292
- const root = record(raw);
1293
- const source = Array.isArray(raw) ? raw : Array.isArray(root.reviews) ? root.reviews : [];
1294
- const byId = new Map();
1295
- let invalid = 0;
1296
- let duplicateConflict = 0;
1297
- for (const item of source.slice(0, MAX_CASE_OBSERVED_EFFECT_REVIEWS * 4)) {
1298
- const normalized = normalizeObservedEffectReview(item, fallback, validPinIds, validSightingIds, options);
1299
- if (!normalized) {
1300
- invalid += 1;
1301
- continue;
1302
- }
1303
- const existing = byId.get(normalized.id);
1304
- if (!existing)
1305
- byId.set(normalized.id, normalized);
1306
- else if (JSON.stringify(normalized) !== JSON.stringify(existing)) {
1307
- duplicateConflict += 1;
1308
- if (JSON.stringify(normalized) < JSON.stringify(existing))
1309
- byId.set(normalized.id, normalized);
1310
- }
1311
- }
1312
- const all = [...byId.values()].sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt) || compareCodeUnits(left.id, right.id));
1313
- invalid += Math.max(0, source.length - MAX_CASE_OBSERVED_EFFECT_REVIEWS * 4);
1314
- const omitted = boundedCounter(root.omitted)
1315
- + invalid
1316
- + duplicateConflict
1317
- + Math.max(0, all.length - MAX_CASE_OBSERVED_EFFECT_REVIEWS);
1318
- const preV13HistoryUnavailable = options.sourceVersion != null && options.sourceVersion <= 12
1319
- ? true
1320
- : root.preV13HistoryUnavailable === true;
1321
- const retainedLimitations = limitations(root.limitations).filter((item) => !/^\d+ earlier observed-effect reviews? omitted by bounded retention\.$/u.test(item));
1322
- return {
1323
- reviews: all.slice(-MAX_CASE_OBSERVED_EFFECT_REVIEWS),
1324
- omitted,
1325
- preV13HistoryUnavailable,
1326
- limitations: lifecycleLimitations([
1327
- ...(omitted ? [`${omitted} earlier observed-effect review${omitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
1328
- ...(invalid ? [`${invalid} malformed or unlinked observed-effect review${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
1329
- ...(duplicateConflict ? [`${duplicateConflict} conflicting observed-effect review identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
1330
- ...(preV13HistoryUnavailable ? ['Migrated from a pre-v13 Case; earlier independent observed-effect review history is unavailable.'] : []),
1331
- 'Observed-effect reviews are independent point-in-time records; provider workflow events do not create or replace them.',
1332
- ...retainedLimitations,
1333
- ]),
1334
- };
1335
- }
1336
- export function appendCaseObservedEffectReview(current, raw, now, validPinIds, validSightingIds) {
1337
- const item = record(raw);
1338
- const created = normalizeObservedEffectReview({ ...item, id: freshId('effect-review'), createdAt: now }, now, validPinIds, validSightingIds);
1339
- if (!created)
1340
- throw new Error('An independent observed-effect review requires a valid state, time, source class, and source.');
1341
- return normalizeCaseObservedEffectHistory({
1342
- reviews: [...current.reviews, created],
1343
- omitted: current.omitted,
1344
- preV13HistoryUnavailable: current.preV13HistoryUnavailable,
1345
- limitations: current.limitations,
1346
- }, now, validPinIds, validSightingIds);
1347
- }
1348
- export function mergeCaseObservedEffectHistories(local, imported, fallback, validPinIds, validSightingIds) {
1349
- return normalizeCaseObservedEffectHistory({
1350
- reviews: [...local.reviews, ...imported.reviews],
1351
- omitted: Math.max(local.omitted, imported.omitted),
1352
- preV13HistoryUnavailable: local.preV13HistoryUnavailable || imported.preV13HistoryUnavailable,
1353
- limitations: [...local.limitations, ...imported.limitations],
1354
- }, fallback, validPinIds, validSightingIds);
1355
- }
1356
- function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options = {}, linkContext = {}) {
1357
- const item = record(raw);
1358
- if (typeof item.reason !== 'string' || !CLOSURE_REASONS.has(item.reason))
1359
- return null;
1360
- const reason = item.reason;
1361
- const summary = text(item.summary, MAX_RESPONSE_RATIONALE_LENGTH);
1362
- if (!summary)
1363
- return null;
1364
- const createdAt = iso(item.createdAt, fallback, options);
1365
- const observedEffectReviewId = typeof item.observedEffectReviewId === 'string'
1366
- && SAFE_ID_RE.test(item.observedEffectReviewId)
1367
- && (!validReviewIds || validReviewIds.has(item.observedEffectReviewId))
1368
- ? item.observedEffectReviewId
1369
- : null;
1370
- const actionId = typeof item.actionId === 'string'
1371
- && SAFE_ID_RE.test(item.actionId)
1372
- && (!validActionIds || validActionIds.has(item.actionId))
1373
- ? item.actionId
1374
- : null;
1375
- const linkedReview = observedEffectReviewId ? linkContext.reviewEvidence?.get(observedEffectReviewId) : undefined;
1376
- const reviewPredatesClosure = linkedReview !== undefined
1377
- && Date.parse(linkedReview.observedAt) <= Date.parse(createdAt)
1378
- && Date.parse(linkedReview.createdAt) <= Date.parse(createdAt);
1379
- if (linkContext.reviewEvidence && reason === 'independently_not_reproduced'
1380
- && (!reviewPredatesClosure || linkedReview?.state !== 'not_reproduced'))
1381
- return null;
1382
- if (linkContext.reviewEvidence && reason === 'infrastructure_changed'
1383
- && (!reviewPredatesClosure || linkedReview?.state !== 'changed'))
1384
- return null;
1385
- const linkedProviderEvents = actionId ? linkContext.providerResolutionEvents?.get(actionId) ?? [] : [];
1386
- if (linkContext.providerResolutionEvents && reason === 'provider_reported_resolution_not_independently_checked'
1387
- && !linkedProviderEvents.some((event) => Date.parse(event.occurredAt) <= Date.parse(createdAt)))
1388
- return null;
1389
- const linkLimitations = [
1390
- ...(item.observedEffectReviewId != null && !observedEffectReviewId ? ['A malformed or dangling observed-effect review reference was omitted from this closure.'] : []),
1391
- ...(item.actionId != null && !actionId ? ['A malformed or dangling response-action reference was omitted from this closure.'] : []),
1392
- ];
1393
- return {
1394
- id: safeId(item.id, 'case-closure', { reason: item.reason, summary, createdAt }),
1395
- reason,
1396
- summary,
1397
- observedEffectReviewId,
1398
- actionId,
1399
- limitations: limitations([...linkLimitations, ...limitations(item.limitations)]),
1400
- createdAt,
1401
- };
1402
- }
1403
- export function buildCaseClosureLinkContext(observedEffects, actions) {
1404
- return {
1405
- reviewEvidence: new Map(observedEffects.reviews.map((review) => [review.id, {
1406
- state: review.state,
1407
- observedAt: review.observedAt,
1408
- createdAt: review.createdAt,
1409
- }])),
1410
- providerResolutionEvents: new Map(actions.map((action) => [action.id, action.history
1411
- .filter((event) => event.applied && event.providerOutcome === 'provider_reports_resolved')
1412
- .map((event) => ({ eventId: event.id, occurredAt: event.occurredAt }))])),
1413
- };
1414
- }
1415
- export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, validActionIds, options = {}, linkContext = {}) {
1416
- const root = record(raw);
1417
- const source = Array.isArray(raw) ? raw : Array.isArray(root.records) ? root.records : [];
1418
- const byId = new Map();
1419
- let invalid = 0;
1420
- let duplicateConflict = 0;
1421
- for (const item of source.slice(0, MAX_CASE_CLOSURES * 4)) {
1422
- const normalized = normalizeClosure(item, fallback, validReviewIds, validActionIds, options, linkContext);
1423
- if (!normalized) {
1424
- invalid += 1;
1425
- continue;
1426
- }
1427
- const existing = byId.get(normalized.id);
1428
- if (!existing)
1429
- byId.set(normalized.id, normalized);
1430
- else if (JSON.stringify(normalized) !== JSON.stringify(existing)) {
1431
- duplicateConflict += 1;
1432
- if (JSON.stringify(normalized) < JSON.stringify(existing))
1433
- byId.set(normalized.id, normalized);
1434
- }
1435
- }
1436
- const all = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id));
1437
- invalid += Math.max(0, source.length - MAX_CASE_CLOSURES * 4);
1438
- const omitted = boundedCounter(root.omitted)
1439
- + invalid
1440
- + duplicateConflict
1441
- + Math.max(0, all.length - MAX_CASE_CLOSURES);
1442
- const preV13HistoryUnavailable = options.sourceVersion != null && options.sourceVersion <= 12
1443
- ? true
1444
- : root.preV13HistoryUnavailable === true;
1445
- const retainedLimitations = limitations(root.limitations).filter((item) => !/^\d+ earlier closure records? omitted by bounded retention\.$/u.test(item));
1446
- return {
1447
- records: all.slice(-MAX_CASE_CLOSURES),
1448
- omitted,
1449
- preV13HistoryUnavailable,
1450
- limitations: lifecycleLimitations([
1451
- ...(omitted ? [`${omitted} earlier closure record${omitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
1452
- ...(invalid ? [`${invalid} malformed, unsupported, or unlinked closure record${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
1453
- ...(duplicateConflict ? [`${duplicateConflict} conflicting closure record identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
1454
- ...(preV13HistoryUnavailable ? ['Migrated from a pre-v13 Case; earlier deliberate closure history is unavailable.'] : []),
1455
- 'Closure records are deliberate analyst actions and do not establish absence, safety, provider performance, or legal sufficiency.',
1456
- ...retainedLimitations,
1457
- ]),
1458
- };
1459
- }
1460
- export function appendCaseClosure(current, raw, now, observedEffects, actions) {
1461
- const item = record(raw);
1462
- const reason = typeof item.reason === 'string' && CLOSURE_REASONS.has(item.reason)
1463
- ? item.reason
1464
- : null;
1465
- const review = typeof item.observedEffectReviewId === 'string'
1466
- ? observedEffects.reviews.find((candidate) => candidate.id === item.observedEffectReviewId) ?? null
1467
- : null;
1468
- const action = typeof item.actionId === 'string'
1469
- ? actions.find((candidate) => candidate.id === item.actionId) ?? null
1470
- : null;
1471
- if (reason === 'independently_not_reproduced'
1472
- && (review?.state !== 'not_reproduced' || 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 not-reproduced review.');
1474
- }
1475
- if (reason === 'infrastructure_changed'
1476
- && (review?.state !== 'changed' || Date.parse(review.observedAt) > Date.parse(now) || Date.parse(review.createdAt) > Date.parse(now))) {
1477
- throw new Error('This closure reason requires a linked independent changed review.');
1478
- }
1479
- if (reason === 'provider_reported_resolution_not_independently_checked'
1480
- && (action?.providerOutcome !== 'provider_reports_resolved'
1481
- || !action.history.some((event) => event.applied
1482
- && event.providerOutcome === 'provider_reports_resolved'
1483
- && Date.parse(event.occurredAt) <= Date.parse(now)))) {
1484
- throw new Error('This closure reason requires a linked typed provider-reported-resolution outcome.');
1485
- }
1486
- const linkContext = buildCaseClosureLinkContext(observedEffects, actions);
1487
- 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);
1488
- if (!created || !reason)
1489
- throw new Error('A deliberate closure requires a typed reason and summary.');
1490
- return normalizeCaseClosureHistory({
1491
- records: [...current.records, created],
1492
- omitted: current.omitted,
1493
- preV13HistoryUnavailable: current.preV13HistoryUnavailable,
1494
- limitations: current.limitations,
1495
- }, now, new Set(observedEffects.reviews.map((candidate) => candidate.id)), new Set(actions.map((candidate) => candidate.id)), {}, linkContext);
1496
- }
1497
- export function mergeCaseClosureHistories(local, imported, fallback, validReviewIds, validActionIds, linkContext = {}) {
1498
- return normalizeCaseClosureHistory({
1499
- records: [...local.records, ...imported.records],
1500
- omitted: Math.max(local.omitted, imported.omitted),
1501
- preV13HistoryUnavailable: local.preV13HistoryUnavailable || imported.preV13HistoryUnavailable,
1502
- limitations: [...local.limitations, ...imported.limitations],
1503
- }, fallback, validReviewIds, validActionIds, {}, linkContext);
1504
- }
1505
- export function buildCaseResponseLifecycleSummary(input) {
1506
- const providerEvents = (input.actions ?? []).flatMap((action) => action.history
1507
- .filter((event) => event.providerOutcome)
1508
- .map((event) => ({ action, event })));
1509
- providerEvents.sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt)
1510
- || compareCodeUnits(right.event.id, left.event.id));
1511
- const providerTime = providerEvents[0]?.event.occurredAt ?? null;
1512
- const providerEventsAtLatestTime = providerTime === null
1513
- ? []
1514
- : providerEvents.filter(({ event }) => event.occurredAt === providerTime);
1515
- const latestProvider = providerEventsAtLatestTime.length === 1 && providerEventsAtLatestTime[0].event.applied
1516
- ? providerEventsAtLatestTime[0]
1517
- : null;
1518
- const providerOutcomeState = providerEvents.length === 0
1519
- ? 'missing'
1520
- : latestProvider ? 'available' : 'ambiguous';
1521
- const reviews = [...(input.observedEffects?.reviews ?? [])]
1522
- .sort((left, right) => Date.parse(right.observedAt) - Date.parse(left.observedAt) || compareCodeUnits(right.id, left.id));
1523
- const latestObserved = reviews[0] ?? null;
1524
- const changedReviews = reviews.filter((review) => review.state === 'changed');
1525
- const changedTime = changedReviews[0]?.observedAt ?? null;
1526
- const changedAtLatestTime = changedTime === null
1527
- ? []
1528
- : changedReviews.filter((review) => review.observedAt === changedTime);
1529
- const latestObservedChangeAt = changedAtLatestTime.length === 1 ? changedTime : null;
1530
- const observedChangeState = changedReviews.length === 0
1531
- ? 'missing'
1532
- : latestObservedChangeAt ? 'available' : 'ambiguous';
1533
- const latestClosure = [...(input.closures?.records ?? [])]
1534
- .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || compareCodeUnits(right.id, left.id))[0] ?? null;
1535
- return {
1536
- providerOutcomeState,
1537
- latestProviderOutcome: latestProvider ? {
1538
- actionId: latestProvider.action.id,
1539
- eventId: latestProvider.event.id,
1540
- outcome: latestProvider.event.providerOutcome,
1541
- occurredAt: latestProvider.event.occurredAt,
1542
- reference: latestProvider.event.reference,
1543
- } : null,
1544
- observedChangeState,
1545
- latestObservedEffect: latestObserved ? {
1546
- reviewId: latestObserved.id,
1547
- state: latestObserved.state,
1548
- observedAt: latestObserved.observedAt,
1549
- sourceClass: latestObserved.sourceClass,
1550
- source: latestObserved.source,
1551
- } : null,
1552
- latestObservedChangeAt,
1553
- latestClosure,
1554
- };
1555
- }
1556
505
  export function buildCaseInvestigationTrail(input) {
1557
506
  return [
1558
507
  ...(input.assertions ?? []).map((item) => ({
@@ -1587,15 +536,15 @@ export function buildCaseInvestigationTrail(input) {
1587
536
  id: `sighting:${item.id}`,
1588
537
  kind: 'sighting',
1589
538
  label: `${item.state.replaceAll('_', ' ')} · ${item.category}`,
1590
- detail: `${item.source} · ${item.completeness}`,
1591
- createdAt: item.observedAt,
539
+ detail: `${item.source} · ${item.completeness} · observed ${item.observedAt ?? 'time unavailable'}`,
540
+ createdAt: item.createdAt,
1592
541
  })),
1593
542
  ...(input.observedEffects?.reviews ?? []).map((item) => ({
1594
543
  id: `observed-effect:${item.id}`,
1595
544
  kind: 'observed_effect',
1596
545
  label: `independent effect · ${item.state.replaceAll('_', ' ')}`,
1597
- detail: `${item.source} · ${item.completeness}`,
1598
- createdAt: item.observedAt,
546
+ detail: `${item.source} · ${item.completeness} · observed ${item.observedAt}`,
547
+ createdAt: item.createdAt,
1599
548
  })),
1600
549
  ...(input.closures?.records ?? []).map((item) => ({
1601
550
  id: `closure:${item.id}`,