@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
@@ -0,0 +1,654 @@
1
+ // Append-only response actions: transitions, reconciliation and bounded history.
2
+ import { CASE_SCHEMA_VERSION, MAX_CASE_ACTIONS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, } from "../contracts/case-portability.mjs";
3
+ import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
4
+ import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_PROVIDER_OUTCOMES, } from "./case-response-records.mjs";
5
+ import { SAFE_ID_RE, boundedCounter, compareCodeUnits, deterministicId, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
6
+ const ACTION_TYPES = new Set(CASE_ACTION_TYPES);
7
+ const ACTION_STATES = new Set(CASE_ACTION_STATES);
8
+ const PROVIDER_OUTCOMES = new Set(CASE_PROVIDER_OUTCOMES);
9
+ const ACTION_EVENT_SOURCE_CLASSES = new Set(CASE_ACTION_EVENT_SOURCE_CLASSES);
10
+ function currentActionNormalizationOptions(validEvidencePinIds) {
11
+ return validEvidencePinIds
12
+ ? { sourceVersion: CASE_SCHEMA_VERSION, validEvidencePinIds }
13
+ : { sourceVersion: CASE_SCHEMA_VERSION };
14
+ }
15
+ const LEGACY_ACTION_STATE_MAP = Object.freeze({
16
+ planned: 'drafting',
17
+ ready_for_review: 'ready_for_review',
18
+ submitted: 'submitted',
19
+ acknowledged: 'acknowledged',
20
+ resolved: 'terminal',
21
+ closed: 'terminal',
22
+ });
23
+ const ACTION_TRANSITIONS = Object.freeze({
24
+ drafting: new Set(['ready_for_review', 'terminal']),
25
+ ready_for_review: new Set(['drafting', 'reviewed', 'terminal']),
26
+ reviewed: new Set(['drafting', 'authorised', 'terminal']),
27
+ authorised: new Set(['drafting', 'submitted', 'terminal']),
28
+ submitted: new Set(['submitted', 'acknowledged', 'terminal']),
29
+ acknowledged: new Set(['acknowledged', 'terminal']),
30
+ terminal: new Set(),
31
+ });
32
+ function bytes(value) {
33
+ try {
34
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
35
+ }
36
+ catch {
37
+ return Number.POSITIVE_INFINITY;
38
+ }
39
+ }
40
+ export function isLegalCaseActionTransition(previousState, nextState, sourceClass = 'analyst') {
41
+ if (previousState === null) {
42
+ return sourceClass === 'migration'
43
+ || nextState === 'drafting' && (sourceClass === 'analyst' || sourceClass === 'browser_local');
44
+ }
45
+ if (!ACTION_TRANSITIONS[previousState].has(nextState))
46
+ return false;
47
+ if (sourceClass === 'provider' || sourceClass === 'import') {
48
+ return (previousState === 'submitted' || previousState === 'acknowledged')
49
+ && (nextState === 'submitted' || nextState === 'acknowledged' || nextState === 'terminal');
50
+ }
51
+ if (sourceClass === 'browser_local') {
52
+ return nextState === 'drafting' && ['ready_for_review', 'reviewed', 'authorised'].includes(previousState);
53
+ }
54
+ return sourceClass === 'analyst';
55
+ }
56
+ function normalizeActionEvent(raw, actionId, options = {}) {
57
+ const item = record(raw);
58
+ const previousState = item.previousState === null
59
+ ? null
60
+ : typeof item.previousState === 'string' && ACTION_STATES.has(item.previousState)
61
+ ? item.previousState
62
+ : undefined;
63
+ const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
64
+ ? item.nextState
65
+ : null;
66
+ const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
67
+ ? item.sourceClass
68
+ : null;
69
+ const occurredAt = optionalIso(item.occurredAt, options);
70
+ if (previousState === undefined || !nextState || !sourceClass || !occurredAt
71
+ || !isLegalCaseActionTransition(previousState, nextState, sourceClass)) {
72
+ return null;
73
+ }
74
+ const migrationSnapshot = sourceClass === 'migration' && previousState === null;
75
+ if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)
76
+ && sourceClass !== 'analyst' && !migrationSnapshot)
77
+ return null;
78
+ const provenance = text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || `${sourceClass}_record`;
79
+ const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
80
+ ? item.providerOutcome
81
+ : null;
82
+ if (providerOutcome && !['submitted', 'acknowledged', 'terminal'].includes(nextState) && sourceClass !== 'migration') {
83
+ return null;
84
+ }
85
+ if (providerOutcome && previousState === 'authorised' && nextState === 'submitted' && !migrationSnapshot) {
86
+ return null;
87
+ }
88
+ if (providerOutcome === 'no_response' && sourceClass === 'provider')
89
+ return null;
90
+ if (nextState === 'terminal' && previousState !== null
91
+ && ['drafting', 'ready_for_review', 'reviewed', 'authorised'].includes(previousState)
92
+ && (sourceClass !== 'analyst' || providerOutcome !== 'withdrawn')) {
93
+ return null;
94
+ }
95
+ if (providerOutcome === 'withdrawn'
96
+ && (nextState !== 'terminal' || (sourceClass !== 'analyst' && !migrationSnapshot)))
97
+ return null;
98
+ const candidateEvidencePinId = typeof item.evidencePinId === 'string' && SAFE_ID_RE.test(item.evidencePinId)
99
+ ? item.evidencePinId
100
+ : null;
101
+ const evidencePinId = candidateEvidencePinId
102
+ && (!options.validEvidencePinIds || options.validEvidencePinIds.has(candidateEvidencePinId))
103
+ ? candidateEvidencePinId
104
+ : null;
105
+ const eventMaterial = {
106
+ previousState,
107
+ nextState,
108
+ occurredAt,
109
+ sourceClass,
110
+ provenance,
111
+ reference: text(item.reference, MAX_RESPONSE_REFERENCE_LENGTH) || null,
112
+ evidencePinId,
113
+ limitations: limitations([
114
+ ...(item.evidencePinId != null && !evidencePinId ? ['A malformed or dangling evidence-pin reference was omitted from this transition.'] : []),
115
+ ...limitations(item.limitations),
116
+ ]),
117
+ providerOutcome,
118
+ outcomeDetail: text(item.outcomeDetail, MAX_RESPONSE_RATIONALE_LENGTH) || null,
119
+ originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
120
+ ? item.originActionId
121
+ : null,
122
+ };
123
+ return {
124
+ id: safeId(item.id, 'action-event', { actionId, ...eventMaterial }),
125
+ ...eventMaterial,
126
+ applied: false,
127
+ };
128
+ }
129
+ function legacyActionState(value) {
130
+ if (typeof value === 'string' && ACTION_STATES.has(value))
131
+ return value;
132
+ if (typeof value === 'string' && LEGACY_ACTION_STATE_MAP[value])
133
+ return LEGACY_ACTION_STATE_MAP[value];
134
+ return 'drafting';
135
+ }
136
+ function legacyActionEvent(item, actionId, createdAt, fallback, options) {
137
+ const nextState = legacyActionState(item.state);
138
+ const occurredAt = iso(item.updatedAt, createdAt || fallback, options);
139
+ const sourceVersion = options.sourceVersion;
140
+ const provenance = sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12
141
+ ? 'case_v12_legacy_snapshot'
142
+ : 'legacy_action_snapshot';
143
+ const legacyState = typeof item.state === 'string' ? item.state : 'planned';
144
+ const migrationLimitation = sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12
145
+ ? `Migrated from the Case v12 current state "${text(legacyState, 40)}"; pre-v13 transition history is unavailable.`
146
+ : 'Recovered a legacy current-state action; earlier transition history is unavailable.';
147
+ const eventMaterial = {
148
+ previousState: null,
149
+ nextState,
150
+ occurredAt,
151
+ sourceClass: 'migration',
152
+ provenance,
153
+ reference: text(item.reference, MAX_RESPONSE_REFERENCE_LENGTH) || null,
154
+ evidencePinId: null,
155
+ limitations: limitations([migrationLimitation, ...limitations(item.contactLimitations)]),
156
+ providerOutcome: !(sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12)
157
+ && typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
158
+ ? item.providerOutcome
159
+ : null,
160
+ outcomeDetail: text(item.outcome, MAX_RESPONSE_RATIONALE_LENGTH) || null,
161
+ originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
162
+ ? item.originActionId
163
+ : null,
164
+ };
165
+ return {
166
+ id: deterministicId('action-event', { actionId, ...eventMaterial }),
167
+ ...eventMaterial,
168
+ applied: true,
169
+ };
170
+ }
171
+ function currentActionRecoveryEvent(actionId, createdAt) {
172
+ const eventMaterial = {
173
+ previousState: null,
174
+ nextState: 'drafting',
175
+ occurredAt: createdAt,
176
+ sourceClass: 'browser_local',
177
+ provenance: 'case_v13_history_recovery',
178
+ reference: null,
179
+ evidencePinId: null,
180
+ limitations: ['The v13 action had no valid transition history. Mutable state, reference, and outcome projections were ignored.'],
181
+ providerOutcome: null,
182
+ outcomeDetail: null,
183
+ originActionId: null,
184
+ };
185
+ return {
186
+ id: deterministicId('action-event', { actionId, ...eventMaterial }),
187
+ ...eventMaterial,
188
+ applied: true,
189
+ };
190
+ }
191
+ function actionEventContent(event) {
192
+ const { applied: _applied, ...material } = event;
193
+ return JSON.stringify(material);
194
+ }
195
+ function projectActionHistory(source, omitted) {
196
+ let state = null;
197
+ let conflicts = 0;
198
+ const history = [];
199
+ for (let start = 0; start < source.length;) {
200
+ let end = start + 1;
201
+ while (end < source.length && source[end].occurredAt === source[start].occurredAt)
202
+ end += 1;
203
+ const cohort = source.slice(start, end);
204
+ if (state === null && omitted > 0 && start === 0 && cohort[0].previousState !== null)
205
+ state = cohort[0].previousState;
206
+ while (cohort.length) {
207
+ // State prerequisites order equal-time transitions. Competing successors
208
+ // retain the existing deterministic conflict order; times are unchanged.
209
+ const eligible = cohort.findIndex((event) => event.previousState === state);
210
+ const [event] = cohort.splice(eligible < 0 ? 0 : eligible, 1);
211
+ const applied = event.previousState === state
212
+ && isLegalCaseActionTransition(event.previousState, event.nextState, event.sourceClass);
213
+ if (applied)
214
+ state = event.nextState;
215
+ else
216
+ conflicts += 1;
217
+ history.push({ ...event, applied });
218
+ }
219
+ start = end;
220
+ }
221
+ return { history, state: state ?? 'drafting', conflicts };
222
+ }
223
+ function normalizeActionHistory(raw, item, actionId, createdAt, fallback, options) {
224
+ const source = Array.isArray(raw) ? raw : [];
225
+ let omitted = boundedCounter(item.historyOmitted);
226
+ let invalid = 0;
227
+ let duplicateConflict = 0;
228
+ const byId = new Map();
229
+ for (const candidate of source.slice(0, MAX_CASE_ACTION_EVENTS_PER_ACTION * 4)) {
230
+ const event = normalizeActionEvent(candidate, actionId, options);
231
+ if (!event) {
232
+ invalid += 1;
233
+ continue;
234
+ }
235
+ const existing = byId.get(event.id);
236
+ if (!existing)
237
+ byId.set(event.id, event);
238
+ else if (actionEventContent(existing) !== actionEventContent(event)) {
239
+ duplicateConflict += 1;
240
+ if (actionEventContent(event) < actionEventContent(existing))
241
+ byId.set(event.id, event);
242
+ }
243
+ }
244
+ invalid += Math.max(0, source.length - MAX_CASE_ACTION_EVENTS_PER_ACTION * 4);
245
+ if (!byId.size) {
246
+ const recovered = options.sourceVersion != null && options.sourceVersion <= 12
247
+ ? legacyActionEvent(item, actionId, createdAt, fallback, options)
248
+ : currentActionRecoveryEvent(actionId, createdAt);
249
+ byId.set(recovered.id, recovered);
250
+ }
251
+ let history = [...byId.values()].sort((left, right) => Date.parse(left.occurredAt) - Date.parse(right.occurredAt) || compareCodeUnits(left.id, right.id));
252
+ if (history.length > MAX_CASE_ACTION_EVENTS_PER_ACTION) {
253
+ omitted += history.length - MAX_CASE_ACTION_EVENTS_PER_ACTION;
254
+ history = history.slice(-MAX_CASE_ACTION_EVENTS_PER_ACTION);
255
+ }
256
+ while (history.length > 1 && bytes(history) > MAX_CASE_ACTION_BYTES) {
257
+ history.shift();
258
+ omitted += 1;
259
+ }
260
+ const totalOmitted = omitted + invalid + duplicateConflict;
261
+ const projected = projectActionHistory(history, totalOmitted);
262
+ const retainedLimitations = limitations(item.historyLimitations).filter((item) => !/^\d+ earlier action transition events? omitted by bounded retention\.$/u.test(item)
263
+ && !/^\d+ retained concurrent transition(?: is|s are) not applied to the current-state projection\.$/u.test(item));
264
+ const historyLimitations = lifecycleLimitations([
265
+ ...(totalOmitted ? [`${totalOmitted} earlier action transition event${totalOmitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
266
+ ...(invalid ? [`${invalid} malformed or illegal action transition event${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
267
+ ...(duplicateConflict ? [`${duplicateConflict} conflicting duplicate event identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
268
+ ...(projected.conflicts ? [`${projected.conflicts} retained concurrent transition${projected.conflicts === 1 ? ' is' : 's are'} not applied to the current-state projection.`] : []),
269
+ ...retainedLimitations,
270
+ ]);
271
+ return {
272
+ history: projected.history,
273
+ state: projected.state,
274
+ omitted: totalOmitted,
275
+ historyLimitations,
276
+ };
277
+ }
278
+ function normalizeAction(raw, fallback, options = {}) {
279
+ const item = record(raw);
280
+ const recipient = text(item.recipient, MAX_RESPONSE_RECIPIENT_LENGTH);
281
+ if (!recipient)
282
+ return null;
283
+ const createdAt = iso(item.createdAt, fallback, options);
284
+ const actionId = safeId(item.id, 'action', { recipient, createdAt });
285
+ const history = normalizeActionHistory(item.history, item, actionId, createdAt, fallback, options);
286
+ const applied = history.history.filter((event) => event.applied);
287
+ const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
288
+ const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
289
+ const latestOutcomeDetail = [...applied].reverse().find((event) => event.outcomeDetail) ?? null;
290
+ const latestEventAt = applied.at(-1)?.occurredAt ?? createdAt;
291
+ const metadataUpdatedAt = iso(item.metadataUpdatedAt ?? item.updatedAt, createdAt, options);
292
+ const updatedAt = Date.parse(latestEventAt) > Date.parse(metadataUpdatedAt) ? latestEventAt : metadataUpdatedAt;
293
+ const contactSource = text(item.contactSource, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_supplied';
294
+ const legacyPlatformReview = options.sourceVersion != null && options.sourceVersion < 15
295
+ && item.type === 'security_contact_report'
296
+ ? /^Official .+, reviewed (\d{4}-\d{2}-\d{2})$/u.exec(contactSource)
297
+ : null;
298
+ return {
299
+ id: actionId,
300
+ type: legacyPlatformReview
301
+ ? 'platform_report'
302
+ : typeof item.type === 'string' && ACTION_TYPES.has(item.type)
303
+ ? item.type
304
+ : 'internal_review',
305
+ recipient,
306
+ contactSource,
307
+ routeObservedAt: legacyPlatformReview
308
+ ? normalizeExplicitIsoTimestamp(`${legacyPlatformReview[1]}T00:00:00.000Z`)
309
+ : options.sourceVersion != null && options.sourceVersion < 14
310
+ ? null
311
+ : optionalIso(item.routeObservedAt, options),
312
+ routeReviewAfter: options.sourceVersion != null && options.sourceVersion < 15
313
+ ? null
314
+ : optionalIso(item.routeReviewAfter, options),
315
+ contactLimitations: limitations(item.contactLimitations),
316
+ dueAt: optionalIso(item.dueAt, options),
317
+ state: history.state,
318
+ reference: latestReference,
319
+ followUpAt: optionalIso(item.followUpAt, options),
320
+ providerOutcome: latestProviderOutcome?.providerOutcome ?? null,
321
+ outcome: latestProviderOutcome?.outcomeDetail ?? latestOutcomeDetail?.outcomeDetail ?? null,
322
+ originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
323
+ ? item.originActionId
324
+ : null,
325
+ history: history.history,
326
+ historyOmitted: history.omitted,
327
+ historyLimitations: history.historyLimitations,
328
+ createdAt,
329
+ metadataUpdatedAt,
330
+ updatedAt,
331
+ };
332
+ }
333
+ function mergeNormalizedActions(left, right, fallback) {
334
+ const metadataWinner = Date.parse(left.metadataUpdatedAt) > Date.parse(right.metadataUpdatedAt)
335
+ ? left
336
+ : Date.parse(right.metadataUpdatedAt) > Date.parse(left.metadataUpdatedAt)
337
+ ? right
338
+ : JSON.stringify(left) <= JSON.stringify(right) ? left : right;
339
+ return normalizeAction({
340
+ ...metadataWinner,
341
+ id: left.id,
342
+ createdAt: Date.parse(left.createdAt) <= Date.parse(right.createdAt) ? left.createdAt : right.createdAt,
343
+ history: [...left.history, ...right.history],
344
+ historyOmitted: Math.max(left.historyOmitted, right.historyOmitted),
345
+ historyLimitations: [...left.historyLimitations, ...right.historyLimitations],
346
+ }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
347
+ }
348
+ function boundActionCollection(actions, fallback) {
349
+ const keep = new Set();
350
+ for (const action of actions) {
351
+ const latest = action.history.at(-1);
352
+ if (latest)
353
+ keep.add(`${action.id}\u0000${latest.id}`);
354
+ }
355
+ const all = actions.flatMap((action) => action.history.map((event) => ({ action, event })))
356
+ .sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt)
357
+ || compareCodeUnits(right.event.id, left.event.id));
358
+ for (const item of all) {
359
+ if (keep.size >= MAX_CASE_ACTION_EVENTS_PER_CASE)
360
+ break;
361
+ keep.add(`${item.action.id}\u0000${item.event.id}`);
362
+ }
363
+ let bounded = actions.map((action) => {
364
+ const history = action.history.filter((event) => keep.has(`${action.id}\u0000${event.id}`));
365
+ const omitted = action.historyOmitted + action.history.length - history.length;
366
+ return normalizeAction({ ...action, history, historyOmitted: omitted }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
367
+ });
368
+ while (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE) {
369
+ const candidate = bounded
370
+ .flatMap((action) => action.history.slice(0, -1).map((event) => ({ action, event })))
371
+ .sort((left, right) => Date.parse(left.event.occurredAt) - Date.parse(right.event.occurredAt)
372
+ || compareCodeUnits(left.event.id, right.event.id))[0];
373
+ if (!candidate)
374
+ break;
375
+ bounded = bounded.map((action) => action.id !== candidate.action.id
376
+ ? action
377
+ : normalizeAction({
378
+ ...action,
379
+ history: action.history.filter((event) => event.id !== candidate.event.id),
380
+ historyOmitted: action.historyOmitted + 1,
381
+ }, fallback, { sourceVersion: CASE_SCHEMA_VERSION }));
382
+ }
383
+ if (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE && bounded.length > 1) {
384
+ const byteOmissionPattern = /^(\d+) earlier response actions? omitted by the per-Case response-history byte bound\.$/u;
385
+ const previouslyOmitted = bounded.reduce((maximum, action) => {
386
+ const retainedCount = action.historyLimitations.reduce((count, item) => {
387
+ const match = byteOmissionPattern.exec(item);
388
+ return match ? Math.max(count, Number(match[1])) : count;
389
+ }, 0);
390
+ return Math.max(maximum, retainedCount);
391
+ }, 0);
392
+ bounded = bounded.map((action) => ({
393
+ ...action,
394
+ historyLimitations: action.historyLimitations.filter((item) => !byteOmissionPattern.test(item)),
395
+ }));
396
+ let omittedActions = 0;
397
+ while (bounded.length > 1) {
398
+ bounded = bounded.slice(1);
399
+ omittedActions += 1;
400
+ const first = bounded[0];
401
+ const totalOmitted = previouslyOmitted + omittedActions;
402
+ bounded = [{
403
+ ...first,
404
+ historyLimitations: lifecycleLimitations([
405
+ `${totalOmitted} earlier response action${totalOmitted === 1 ? '' : 's'} omitted by the per-Case response-history byte bound.`,
406
+ ...first.historyLimitations,
407
+ ]),
408
+ }, ...bounded.slice(1)];
409
+ if (bytes(bounded) <= MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE)
410
+ return bounded;
411
+ }
412
+ }
413
+ return bounded;
414
+ }
415
+ export function normalizeCaseActions(raw, fallback, options = {}) {
416
+ if (!Array.isArray(raw))
417
+ return [];
418
+ const byId = new Map();
419
+ const inspected = raw.slice(0, MAX_CASE_ACTIONS * 2);
420
+ let invalid = 0;
421
+ for (const item of inspected) {
422
+ const normalized = normalizeAction(item, fallback, options);
423
+ if (!normalized) {
424
+ invalid += 1;
425
+ continue;
426
+ }
427
+ const existing = byId.get(normalized.id);
428
+ byId.set(normalized.id, existing ? mergeNormalizedActions(existing, normalized, fallback) : normalized);
429
+ }
430
+ const normalizedActions = [...byId.values()]
431
+ .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id));
432
+ const uninspected = Math.max(0, raw.length - inspected.length);
433
+ const omitted = Math.max(0, normalizedActions.length - MAX_CASE_ACTIONS);
434
+ const actions = normalizedActions.slice(-MAX_CASE_ACTIONS);
435
+ if (actions.length && (invalid || uninspected || omitted)) {
436
+ const first = actions[0];
437
+ actions[0] = {
438
+ ...first,
439
+ historyLimitations: lifecycleLimitations([
440
+ ...(invalid ? [`${invalid} malformed response-action candidate${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
441
+ ...(uninspected ? [`${uninspected} response-action candidate${uninspected === 1 ? '' : 's'} beyond the bounded inspection window were not traversed.`] : []),
442
+ ...(omitted ? [`${omitted} earlier response action${omitted === 1 ? '' : 's'} omitted by the per-Case action bound.`] : []),
443
+ ...first.historyLimitations,
444
+ ]),
445
+ };
446
+ }
447
+ const actionIds = new Set(actions.map((action) => action.id));
448
+ const linked = actions.map((action) => {
449
+ const originActionId = action.originActionId && actionIds.has(action.originActionId) && action.originActionId !== action.id
450
+ ? action.originActionId
451
+ : null;
452
+ const invalidOrigins = action.history.filter((event) => event.originActionId
453
+ && (!actionIds.has(event.originActionId) || event.originActionId === action.id)).length;
454
+ return {
455
+ ...action,
456
+ originActionId,
457
+ history: action.history.map((event) => ({
458
+ ...event,
459
+ originActionId: event.originActionId && actionIds.has(event.originActionId) && event.originActionId !== action.id
460
+ ? event.originActionId
461
+ : null,
462
+ })),
463
+ historyLimitations: lifecycleLimitations([
464
+ ...action.historyLimitations,
465
+ ...(invalidOrigins || (action.originActionId && !originActionId)
466
+ ? ['One or more dangling or self-referential origin-action links were omitted.']
467
+ : []),
468
+ ]),
469
+ };
470
+ });
471
+ return boundActionCollection(linked, fallback);
472
+ }
473
+ export function appendCaseAction(current, raw, now) {
474
+ if (current.length >= MAX_CASE_ACTIONS) {
475
+ throw new Error(`A Case can retain at most ${MAX_CASE_ACTIONS} response actions. No additional action was retained.`);
476
+ }
477
+ const item = record(raw);
478
+ const id = freshId('action');
479
+ const originActionId = typeof item.originActionId === 'string' && current.some((action) => action.id === item.originActionId)
480
+ ? item.originActionId
481
+ : null;
482
+ if (item.originActionId != null && !originActionId)
483
+ throw new Error('A follow-on action requires an existing originating action.');
484
+ const history = [{
485
+ id: freshId('action-event'),
486
+ previousState: null,
487
+ nextState: 'drafting',
488
+ occurredAt: now,
489
+ sourceClass: 'analyst',
490
+ provenance: 'browser_local_action_creation',
491
+ reference: null,
492
+ evidencePinId: null,
493
+ limitations: [],
494
+ providerOutcome: null,
495
+ outcomeDetail: null,
496
+ originActionId,
497
+ }];
498
+ const created = normalizeAction({
499
+ ...item,
500
+ id,
501
+ originActionId,
502
+ history,
503
+ historyOmitted: 0,
504
+ createdAt: now,
505
+ metadataUpdatedAt: now,
506
+ }, now, { sourceVersion: CASE_SCHEMA_VERSION });
507
+ if (!created)
508
+ throw new Error('An action requires a recipient or internal owner.');
509
+ return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
510
+ }
511
+ export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
512
+ const action = current.find((item) => item.id === actionId);
513
+ if (!action)
514
+ throw new Error('That case action no longer exists.');
515
+ const item = record(raw);
516
+ const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
517
+ ? item.nextState
518
+ : null;
519
+ const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
520
+ ? item.sourceClass
521
+ : 'analyst';
522
+ if (!nextState || !isLegalCaseActionTransition(action.state, nextState, sourceClass)) {
523
+ throw new Error(`The transition from ${action.state.replaceAll('_', ' ')} to ${String(item.nextState || 'that state').replaceAll('_', ' ')} is not permitted.`);
524
+ }
525
+ if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState) && sourceClass !== 'analyst') {
526
+ throw new Error('Readiness, review, authorisation, and submission require an explicit analyst transition.');
527
+ }
528
+ const originActionId = typeof item.originActionId === 'string'
529
+ && item.originActionId !== action.id
530
+ && current.some((candidate) => candidate.id === item.originActionId)
531
+ ? item.originActionId
532
+ : action.originActionId;
533
+ if (item.originActionId != null && originActionId !== item.originActionId) {
534
+ throw new Error('A referral or follow-on transition requires a distinct existing originating action.');
535
+ }
536
+ if (item.evidencePinId != null && (typeof item.evidencePinId !== 'string'
537
+ || !SAFE_ID_RE.test(item.evidencePinId) || (validPinIds && !validPinIds.has(item.evidencePinId)))) {
538
+ throw new Error('An action transition evidence pin must reference a retained Case evidence pin.');
539
+ }
540
+ const occurredAt = optionalIso(item.occurredAt) ?? now;
541
+ const event = normalizeActionEvent({
542
+ ...item,
543
+ id: freshId('action-event'),
544
+ previousState: action.state,
545
+ nextState,
546
+ occurredAt,
547
+ sourceClass,
548
+ provenance: text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || 'browser_local_explicit_transition',
549
+ originActionId,
550
+ }, action.id, currentActionNormalizationOptions(validPinIds));
551
+ if (!event)
552
+ throw new Error('The action transition contains an invalid time, provider outcome, or provenance field.');
553
+ const updated = normalizeAction({
554
+ ...action,
555
+ history: [...action.history, event],
556
+ historyOmitted: action.historyOmitted,
557
+ }, now, currentActionNormalizationOptions(validPinIds));
558
+ return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
559
+ }
560
+ const ACTION_REVIEW_MATERIAL_FIELDS = [
561
+ 'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId',
562
+ ];
563
+ export function updateCaseAction(current, raw, now, validPinIds) {
564
+ const patch = record(raw);
565
+ const id = typeof patch.id === 'string' && SAFE_ID_RE.test(patch.id) ? patch.id : '';
566
+ const existing = current.find((item) => item.id === id);
567
+ if (!existing)
568
+ throw new Error('That case action no longer exists.');
569
+ if (Object.hasOwn(patch, 'state') || Object.hasOwn(patch, 'outcome') || Object.hasOwn(patch, 'providerOutcome') || Object.hasOwn(patch, 'reference')) {
570
+ throw new Error('State, reference, and provider outcomes must be recorded as an append-only action transition.');
571
+ }
572
+ const metadata = {
573
+ ...existing,
574
+ ...Object.fromEntries(ACTION_REVIEW_MATERIAL_FIELDS
575
+ .filter((field) => Object.hasOwn(patch, field))
576
+ .map((field) => [field, patch[field]])),
577
+ dueAt: Object.hasOwn(patch, 'dueAt') ? patch.dueAt : existing.dueAt,
578
+ followUpAt: Object.hasOwn(patch, 'followUpAt') ? patch.followUpAt : existing.followUpAt,
579
+ metadataUpdatedAt: now,
580
+ };
581
+ if (Object.hasOwn(patch, 'originActionId') && patch.originActionId !== null
582
+ && (typeof patch.originActionId !== 'string' || patch.originActionId === id || !current.some((item) => item.id === patch.originActionId))) {
583
+ throw new Error('A follow-on action requires a distinct existing originating action.');
584
+ }
585
+ let updated = normalizeAction(metadata, now, currentActionNormalizationOptions(validPinIds));
586
+ if (!updated)
587
+ throw new Error('An action requires a recipient or internal owner.');
588
+ const materialChanged = ACTION_REVIEW_MATERIAL_FIELDS
589
+ .some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
590
+ if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
591
+ throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
592
+ }
593
+ if (materialChanged && ['ready_for_review', 'reviewed', 'authorised'].includes(existing.state)) {
594
+ const invalidated = normalizeAction({
595
+ ...updated,
596
+ history: [...updated.history, {
597
+ id: freshId('action-event'),
598
+ previousState: existing.state,
599
+ nextState: 'drafting',
600
+ occurredAt: now,
601
+ sourceClass: 'browser_local',
602
+ provenance: 'material_action_change',
603
+ reference: null,
604
+ evidencePinId: null,
605
+ limitations: ['Material action inputs changed after review; prior readiness, review, or authorisation no longer applies.'],
606
+ providerOutcome: null,
607
+ outcomeDetail: null,
608
+ originActionId: updated.originActionId,
609
+ }],
610
+ }, now, currentActionNormalizationOptions(validPinIds));
611
+ if (!invalidated)
612
+ throw new Error('The material action change could not be retained safely.');
613
+ updated = invalidated;
614
+ }
615
+ const interim = current.map((item) => item.id === id ? updated : item);
616
+ return patch.transition !== undefined
617
+ ? appendCaseActionTransition(interim, id, patch.transition, now, validPinIds)
618
+ : normalizeCaseActions(interim, now, currentActionNormalizationOptions(validPinIds));
619
+ }
620
+ export function buildCaseActionOutcomeSummary(actions, nowRaw = new Date().toISOString()) {
621
+ const now = optionalIso(nowRaw) ?? new Date().toISOString();
622
+ const nowTime = Date.parse(now);
623
+ const active = actions.filter((item) => item.state !== 'terminal');
624
+ const latestOutcomes = actions
625
+ .flatMap((action) => action.history.filter((event) => event.applied && event.providerOutcome).map((event) => ({ action, event })))
626
+ .sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt) || compareCodeUnits(right.event.id, left.event.id))
627
+ .slice(0, 5)
628
+ .map(({ action, event }) => ({
629
+ actionId: action.id,
630
+ recipient: action.recipient,
631
+ state: action.state,
632
+ providerOutcome: event.providerOutcome,
633
+ outcomeDetail: event.outcomeDetail,
634
+ occurredAt: event.occurredAt,
635
+ }));
636
+ return {
637
+ total: actions.length,
638
+ active: active.length,
639
+ drafting: actions.filter((item) => item.state === 'drafting').length,
640
+ readyForReview: actions.filter((item) => item.state === 'ready_for_review').length,
641
+ reviewed: actions.filter((item) => item.state === 'reviewed').length,
642
+ authorised: actions.filter((item) => item.state === 'authorised').length,
643
+ submitted: actions.filter((item) => item.state === 'submitted').length,
644
+ acknowledged: actions.filter((item) => item.state === 'acknowledged').length,
645
+ terminal: actions.filter((item) => item.state === 'terminal').length,
646
+ overdue: active.filter((item) => item.dueAt && Date.parse(item.dueAt) < nowTime).length,
647
+ followUpDue: active.filter((item) => item.followUpAt && Date.parse(item.followUpAt) <= nowTime).length,
648
+ withProviderOutcome: actions.filter((item) => item.providerOutcome !== null).length,
649
+ latestOutcomes,
650
+ };
651
+ }
652
+ export function mergeCaseActions(local, imported, fallback, validPinIds) {
653
+ return normalizeCaseActions([...local, ...imported], fallback, currentActionNormalizationOptions(validPinIds));
654
+ }