@slicedearth/whoisleuth-cli 2.3.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (418) hide show
  1. package/NOTICE +8 -2
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/argument-values.mjs +55 -0
  7. package/cli/arguments.mjs +24 -408
  8. package/cli/artifact-validation/case-response.mjs +65 -44
  9. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  10. package/cli/artifact-validation/lookup-evidence.mjs +15 -9
  11. package/cli/artifact-validation/signed-review.mjs +136 -68
  12. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  13. package/cli/artifact-verify.mjs +92 -24
  14. package/cli/assurance-arguments.mjs +56 -0
  15. package/cli/assurance-command-definitions.mjs +179 -0
  16. package/cli/assurance-command-runner.mjs +40 -5
  17. package/cli/bagit-folder.mjs +57 -0
  18. package/cli/bagit-review.mjs +23 -0
  19. package/cli/bounded-json.mjs +1 -1
  20. package/cli/bulk-checkpoint.mjs +1 -1
  21. package/cli/bulk-command-runner.mjs +4 -2
  22. package/cli/bulk-output.mjs +29 -3
  23. package/cli/bulk.mjs +2 -3
  24. package/cli/case-command.mjs +212 -0
  25. package/cli/case-pack.mjs +50 -32
  26. package/cli/ci-report.mjs +6 -3
  27. package/cli/collection-arguments.mjs +123 -0
  28. package/cli/collection-command-definitions.mjs +330 -0
  29. package/cli/collection-preflight.mjs +8 -1
  30. package/cli/command-argument-grammar.mjs +8 -206
  31. package/cli/command-definition.mjs +329 -0
  32. package/cli/command-reference.mjs +94 -1357
  33. package/cli/command-seeds.mjs +18 -0
  34. package/cli/compare.mjs +1 -1
  35. package/cli/completion.mjs +298 -79
  36. package/cli/config-profile.mjs +8 -6
  37. package/cli/ct-event-intake.mjs +16 -16
  38. package/cli/discover.mjs +5 -4
  39. package/cli/discovery-observation-snapshot.mjs +46 -18
  40. package/cli/discovery-scan-command-runner.mjs +5 -3
  41. package/cli/discovery-scan.mjs +7 -4
  42. package/cli/discovery-snapshot.mjs +2 -2
  43. package/cli/discovery-workflow.mjs +5 -5
  44. package/cli/domain-control-monitor.mjs +50 -20
  45. package/cli/domain-control-observations.mjs +151 -103
  46. package/cli/errors.mjs +37 -6
  47. package/cli/evidence-arguments.mjs +46 -0
  48. package/cli/evidence-command-definitions.mjs +69 -0
  49. package/cli/evidence-command-runner.mjs +18 -8
  50. package/cli/evidence-signing.mjs +5 -4
  51. package/cli/external-observation-mapping.mjs +9 -10
  52. package/cli/formatters/terminal-command-formats.mjs +6 -0
  53. package/cli/formatters/terminal-lookup.mjs +24 -46
  54. package/cli/formatters/terminal-shared.mjs +5 -3
  55. package/cli/formatters/terminal-technology.mjs +114 -0
  56. package/cli/history-arguments.mjs +39 -0
  57. package/cli/history-command-definitions.mjs +88 -0
  58. package/cli/indicator-set-command.mjs +33 -0
  59. package/cli/inline-command-families.mjs +6 -46
  60. package/cli/inline-command-runner.mjs +4 -31
  61. package/cli/input.mjs +2 -2
  62. package/cli/intake-command.mjs +107 -0
  63. package/cli/interactive-launcher.mjs +2 -160
  64. package/cli/interchange-report.mjs +8 -10
  65. package/cli/investigation-artifacts.mjs +176 -0
  66. package/cli/investigation-checkpoint.mjs +10 -0
  67. package/cli/investigation-folder.mjs +114 -0
  68. package/cli/investigation-manifest.mjs +1 -112
  69. package/cli/investigation-package-review.mjs +103 -0
  70. package/cli/investigation-plan.mjs +38 -147
  71. package/cli/investigation-recipes.mjs +177 -0
  72. package/cli/investigation-run.mjs +256 -35
  73. package/cli/local-document-checkpoint.mjs +170 -0
  74. package/cli/local-mmdb-review.mjs +197 -17
  75. package/cli/lookalike-calibration.mjs +1 -1
  76. package/cli/lookup-brief.mjs +2 -2
  77. package/cli/lookup-browser.mjs +5 -12
  78. package/cli/lookup-command-runner.mjs +33 -39
  79. package/cli/lookup-diff.mjs +47 -12
  80. package/cli/lookup-plan.mjs +16 -14
  81. package/cli/lookup-reconcile.mjs +15 -12
  82. package/cli/lookup-timeline.mjs +7 -6
  83. package/cli/mail-header-review.mjs +24 -247
  84. package/cli/mail-review.mjs +75 -21
  85. package/cli/manual.mjs +1 -0
  86. package/cli/network-arguments.mjs +51 -0
  87. package/cli/network-command-definitions.mjs +130 -0
  88. package/cli/network-command-runner.mjs +45 -31
  89. package/cli/offline-evidence-review.mjs +39 -6
  90. package/cli/output-file.mjs +31 -5
  91. package/cli/posture.mjs +1 -1
  92. package/cli/registry-doctor.mjs +3 -1
  93. package/cli/registry-fixture-scaffold.mjs +1 -1
  94. package/cli/registry-support.mjs +1 -1
  95. package/cli/retained-artifact-diff.mjs +10 -9
  96. package/cli/review-arguments.mjs +131 -0
  97. package/cli/review-command-definitions.mjs +273 -0
  98. package/cli/review-command-runner.mjs +104 -149
  99. package/cli/risk-calibration.mjs +8 -8
  100. package/cli/runner.mjs +74 -37
  101. package/cli/saved-lookup.mjs +2 -2
  102. package/cli/selected-input-worker.mjs +63 -0
  103. package/cli/sharing-review.mjs +8 -80
  104. package/cli/signer-trust.mjs +104 -0
  105. package/cli/source-reliability.mjs +75 -84
  106. package/cli/support-arguments.mjs +49 -0
  107. package/cli/support-command-definitions.mjs +209 -0
  108. package/cli/support-command-runner.mjs +2 -2
  109. package/cli/terminal-input.mjs +168 -0
  110. package/cli/workflow-arguments.mjs +86 -0
  111. package/cli/workflow-command-definitions.mjs +78 -0
  112. package/cli/workflow-command-runner.mjs +75 -13
  113. package/docs/cli-reference.md +103 -30
  114. package/docs/cli.md +510 -25
  115. package/docs/contextual-reviews.md +155 -0
  116. package/lib/abort.mjs +4 -1
  117. package/lib/analyst-taxonomy.mjs +2 -40
  118. package/lib/application-version.mjs +1 -1
  119. package/lib/availability.mjs +27 -754
  120. package/lib/bounded-contract-normalizers.mjs +4 -0
  121. package/lib/bounded-file.mjs +3 -1
  122. package/lib/bounded-html-document.mjs +125 -0
  123. package/lib/bounded-json.mjs +2 -290
  124. package/lib/browser-library-profile.mjs +99 -130
  125. package/lib/browser-library-worker.mjs +112 -0
  126. package/lib/candidate-provenance-bounds.mjs +2 -0
  127. package/lib/classify.mjs +1 -1
  128. package/lib/client-behavior-profile.mjs +3 -3
  129. package/lib/ct-query.mjs +2 -27
  130. package/lib/ct-response-bounds.mjs +2 -9
  131. package/lib/ct-search.mjs +89 -46
  132. package/lib/dmarc-discovery.mjs +62 -0
  133. package/lib/dns-convergence-review.mjs +21 -8
  134. package/lib/dns-delegation-health.mjs +36 -22
  135. package/lib/dns-inheritance-review.mjs +207 -0
  136. package/lib/dns-intelligence.mjs +44 -29
  137. package/lib/dns-mx.mjs +1 -1
  138. package/lib/dns-query-coverage.mjs +40 -0
  139. package/lib/dnssec-evidence-validation.mjs +11 -3
  140. package/lib/domain-assurance.mjs +1 -1
  141. package/lib/domain-change-packet.mjs +18 -14
  142. package/lib/domain-change-review.mjs +40 -16
  143. package/lib/domain-control-flight-recorder.mjs +301 -124
  144. package/lib/domain-control-manifest.mjs +126 -58
  145. package/lib/domain-posture-analysis.mjs +41 -11
  146. package/lib/domain-posture-parsers.mjs +88 -6
  147. package/lib/domain-posture.mjs +120 -32
  148. package/lib/domain-registration.mjs +392 -0
  149. package/lib/evidence-export-privacy.mjs +568 -0
  150. package/lib/evidence-export.mjs +12 -593
  151. package/lib/evidence-report.mjs +7 -2
  152. package/lib/external-intelligence-risk.mjs +2 -124
  153. package/lib/favicon.mjs +117 -41
  154. package/lib/generated/cisa-kev-catalog.mjs +50 -4
  155. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  156. package/lib/generated/retire-browser-catalog.mjs +2742 -1034
  157. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  158. package/lib/html-signals.mjs +87 -85
  159. package/lib/http-evidence-bounds.mjs +2 -7
  160. package/lib/http-intelligence.mjs +52 -27
  161. package/lib/idn-confusable-policy.mjs +6 -4
  162. package/lib/idn-confusables.mjs +1 -1
  163. package/lib/interchange-fidelity-registry.mjs +10 -0
  164. package/lib/lookup-cache.mjs +34 -11
  165. package/lib/lookup-child-profile-contract.mjs +24 -15
  166. package/lib/lookup-contract-primitives.mjs +1 -1
  167. package/lib/lookup-diagnostics.mjs +2 -1
  168. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  169. package/lib/lookup-progress.mjs +79 -13
  170. package/lib/lookup-response-contract.mjs +1077 -0
  171. package/lib/lookup-response.mjs +11 -24
  172. package/lib/lookup-source-progress.mjs +24 -35
  173. package/lib/lookup.mjs +49 -28
  174. package/lib/observed-network-context.mjs +43 -27
  175. package/lib/opportunity-scoring.mjs +2 -107
  176. package/lib/outbound-identity.mjs +1 -1
  177. package/lib/outbound-request-bounds.mjs +4 -1
  178. package/lib/page-fingerprints.mjs +87 -134
  179. package/lib/page-role-profile.mjs +12 -8
  180. package/lib/perceptual-hash-comparison.mjs +2 -34
  181. package/lib/perceptual-hash.mjs +79 -24
  182. package/lib/portable-generator.mjs +2 -29
  183. package/lib/project-metadata.mjs +2 -8
  184. package/lib/rdap-bootstrap.mjs +72 -22
  185. package/lib/rdap-client.mjs +26 -14
  186. package/lib/rdap-normalization.mjs +33 -18
  187. package/lib/rdap-search-workbench.mjs +1 -1
  188. package/lib/rdap-transport.mjs +47 -20
  189. package/lib/rdap-types.mjs +2 -1
  190. package/lib/rdap.mjs +18 -3
  191. package/lib/registrable-domain.mjs +2 -36
  192. package/lib/registrar-standing-contract.mjs +2 -1
  193. package/lib/registrar-standing.mjs +9 -13
  194. package/lib/registry-capabilities.mjs +7 -3
  195. package/lib/registry-capability-catalogue.mjs +29 -20
  196. package/lib/registry-dates.mjs +2 -190
  197. package/lib/registry-insights.mjs +56 -18
  198. package/lib/response-policy.mjs +86 -48
  199. package/lib/risk-calibration-summary.mjs +2 -338
  200. package/lib/risk-scoring.mjs +2 -345
  201. package/lib/rpki-evidence.mjs +8 -5
  202. package/lib/safe-fetch.mjs +54 -47
  203. package/lib/scoring-evidence-quality.mjs +2 -107
  204. package/lib/security-txt.mjs +10 -7
  205. package/lib/semantic-version.mjs +2 -54
  206. package/lib/service-binding-dns.mjs +35 -0
  207. package/lib/smtp-transport-review.mjs +75 -34
  208. package/lib/sslbl-certificates.generated.mjs +141 -138
  209. package/lib/static-html-analysis.mjs +117 -146
  210. package/lib/structured-data-identity.mjs +2 -1
  211. package/lib/technology-markup.mjs +100 -0
  212. package/lib/threat-intelligence-manifests.mjs +11 -139
  213. package/lib/threat-intelligence-runtime.mjs +24 -52
  214. package/lib/threat-intelligence-targets.mjs +6 -34
  215. package/lib/threat-intelligence-types.mjs +2 -36
  216. package/lib/typosquat-generator.mjs +29 -16
  217. package/lib/urlhaus-intelligence.mjs +8 -3
  218. package/lib/urlscan-intelligence.mjs +1 -1
  219. package/lib/website-enrichment.mjs +388 -0
  220. package/lib/website-security-posture.mjs +30 -6
  221. package/lib/website-technology.mjs +111 -71
  222. package/lib/whois-authority.mjs +55 -5
  223. package/lib/whois-chain.mjs +9 -2
  224. package/lib/whois-common-formats.mjs +5 -5
  225. package/lib/whois-contracts.mjs +2 -1
  226. package/lib/whois-dialects.mjs +2 -2
  227. package/lib/whois-normalization.mjs +3 -1
  228. package/lib/whois-parser-support.mjs +8 -1
  229. package/lib/whois-parser.mjs +5 -3
  230. package/lib/whois-transport.mjs +23 -7
  231. package/package.json +14 -14
  232. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  233. package/packages/analysis/bounded-json.mjs +330 -0
  234. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  235. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  236. package/packages/analysis/ct-query.mjs +27 -0
  237. package/packages/analysis/ct-response-bounds.mjs +9 -0
  238. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  239. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  240. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  241. package/packages/analysis/opportunity-scoring.mjs +107 -0
  242. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  243. package/packages/analysis/portable-generator.mjs +29 -0
  244. package/packages/analysis/project-metadata.mjs +8 -0
  245. package/packages/analysis/registrable-domain.mjs +56 -0
  246. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  247. package/packages/analysis/risk-scoring.mjs +345 -0
  248. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  249. package/packages/analysis/semantic-version.mjs +54 -0
  250. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  251. package/packages/cases/case-attachment-model.mjs +83 -0
  252. package/packages/cases/case-evidence-links.mjs +135 -0
  253. package/packages/cases/case-evidence-model.mjs +229 -69
  254. package/packages/cases/case-evidence-quality.mjs +5 -0
  255. package/packages/cases/case-export-input.mjs +55 -0
  256. package/packages/cases/case-incident-context.mjs +52 -0
  257. package/packages/cases/case-markdown.mjs +11 -0
  258. package/packages/cases/case-migration-model.mjs +98 -48
  259. package/packages/cases/case-model.mjs +1 -0
  260. package/packages/cases/case-recheck-model.mjs +149 -0
  261. package/packages/cases/case-record-contracts.mjs +1 -1
  262. package/packages/cases/case-record-core.mjs +4 -3
  263. package/packages/cases/case-record-operations.mjs +123 -133
  264. package/packages/cases/case-record-projection.mjs +30 -20
  265. package/packages/cases/case-record-version-input.mjs +19 -0
  266. package/packages/cases/case-report.mjs +114 -67
  267. package/packages/cases/case-requested-evidence.mjs +146 -0
  268. package/packages/cases/case-response-actions.mjs +756 -0
  269. package/packages/cases/case-response-model.mjs +49 -1129
  270. package/packages/cases/case-response-outcomes.mjs +338 -0
  271. package/packages/cases/case-response-packet-render.mjs +157 -0
  272. package/packages/cases/case-response-packet-types.mjs +1 -0
  273. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  274. package/packages/cases/case-response-packet.mjs +58 -593
  275. package/packages/cases/case-response-records.mjs +93 -0
  276. package/packages/cases/case-response-review-inputs.mjs +382 -0
  277. package/packages/cases/case-response-values.mjs +85 -0
  278. package/packages/cases/case-review-package.mjs +45 -0
  279. package/packages/cases/case-selection.mjs +46 -0
  280. package/packages/cases/case-storage-model.mjs +26 -1
  281. package/packages/cases/case-workflow-metadata.mjs +225 -0
  282. package/packages/cases/case-workflow-migration.mjs +43 -0
  283. package/packages/cases/http-summary.mjs +6 -1
  284. package/packages/cases/response-route-freshness.mjs +16 -0
  285. package/packages/comparison/capture-context.mjs +61 -0
  286. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  287. package/packages/comparison/favicon-similarity.mjs +1 -1
  288. package/packages/comparison/page-similarity.mjs +3 -2
  289. package/packages/comparison/relationship-evidence.mjs +103 -14
  290. package/packages/comparison/relationship-provenance.mjs +99 -0
  291. package/packages/contracts/analyst-interchange.mjs +234 -0
  292. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  293. package/packages/contracts/analyst-review-state.mjs +5 -4
  294. package/packages/contracts/case-portability.mjs +370 -114
  295. package/packages/contracts/case-views-contract.mjs +12 -0
  296. package/packages/contracts/case-views.mjs +26 -0
  297. package/packages/contracts/cli-command-semantics.mjs +3 -0
  298. package/packages/contracts/cli-grammar.mjs +6 -0
  299. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  300. package/packages/contracts/cli-lookup.mjs +0 -1
  301. package/packages/contracts/context-review.mjs +24 -0
  302. package/packages/contracts/document-intake.mjs +11 -0
  303. package/packages/contracts/domain-control-flight-recorder.mjs +98 -44
  304. package/packages/contracts/domain-control-manifest.mjs +128 -74
  305. package/packages/contracts/domain-control-monitor.mjs +59 -45
  306. package/packages/contracts/domain-control-review.mjs +200 -121
  307. package/packages/contracts/domain-name.mjs +21 -0
  308. package/packages/contracts/external-observation-interchange.mjs +2 -1
  309. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  310. package/packages/contracts/har-review.mjs +2 -0
  311. package/packages/contracts/http-url.mjs +2 -0
  312. package/packages/contracts/identity-events.mjs +4 -0
  313. package/packages/contracts/investigation-package-limits.mjs +24 -0
  314. package/packages/contracts/investigation-portability.mjs +56 -27
  315. package/packages/contracts/investigation-run.mjs +18 -0
  316. package/packages/contracts/ip-address.mjs +44 -0
  317. package/packages/contracts/lookup-evidence.mjs +35 -0
  318. package/packages/contracts/mail-authentication.mjs +5 -0
  319. package/packages/contracts/message-intake.mjs +31 -0
  320. package/packages/contracts/offline-comparison.mjs +4 -2
  321. package/packages/contracts/page-fingerprints.mjs +15 -0
  322. package/packages/contracts/passive-mail.mjs +10 -0
  323. package/packages/contracts/risk-calibration.mjs +0 -1
  324. package/packages/contracts/schema-compatibility.mjs +1 -1
  325. package/packages/contracts/schema-lifecycle.mjs +83 -153
  326. package/packages/contracts/selected-file-limits.mjs +6 -0
  327. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  328. package/packages/contracts/web-capture.mjs +10 -0
  329. package/packages/contracts/workspace-portability.mjs +219 -238
  330. package/packages/evidence/artifact-integrity.mjs +8 -2
  331. package/packages/evidence/artifact-structure.mjs +105 -0
  332. package/packages/evidence/collection-quality.mjs +67 -0
  333. package/packages/evidence/decision-fact.mjs +4 -1
  334. package/packages/evidence/dns-query-outcome.mjs +72 -0
  335. package/packages/evidence/domain-control-runtime.mjs +155 -27
  336. package/packages/evidence/domain-posture-context.mjs +79 -0
  337. package/packages/evidence/email-recipient.mjs +18 -0
  338. package/packages/evidence/image-regions.mjs +56 -0
  339. package/packages/evidence/latest-observations.mjs +28 -0
  340. package/packages/evidence/lookup-target.mjs +108 -0
  341. package/packages/evidence/observation.mjs +18 -1
  342. package/packages/evidence/passphrase-encryption.mjs +44 -0
  343. package/packages/evidence/png-pixels.mjs +117 -0
  344. package/packages/evidence/public-address-policy.mjs +83 -0
  345. package/packages/evidence/record-identity.mjs +72 -0
  346. package/packages/evidence/registry-dates.mjs +190 -0
  347. package/packages/evidence/retained-file.mjs +69 -0
  348. package/packages/evidence/review-age.mjs +14 -0
  349. package/packages/interchange/bagit.mjs +342 -0
  350. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  351. package/packages/interchange/crc32.mjs +17 -0
  352. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  353. package/packages/interchange/external-findings-import.mjs +93 -61
  354. package/packages/interchange/managed-indicator-export.mjs +35 -0
  355. package/packages/interchange/managed-indicator-set.mjs +210 -0
  356. package/packages/interchange/misp-indicator-export.mjs +93 -0
  357. package/packages/interchange/sharing-policy.mjs +56 -0
  358. package/packages/interchange/stix-indicator-export.mjs +129 -0
  359. package/packages/interchange/web-capture-import.mjs +389 -0
  360. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  361. package/packages/investigation/bulk-mail-exposure.mjs +9 -14
  362. package/packages/investigation/capture-coverage.mjs +57 -0
  363. package/packages/investigation/connector-provenance-review.mjs +100 -0
  364. package/packages/investigation/context-review.mjs +35 -0
  365. package/packages/investigation/document-intake.mjs +81 -0
  366. package/packages/investigation/docx-intake.mjs +133 -0
  367. package/packages/investigation/domain-history-review.mjs +100 -0
  368. package/packages/investigation/domain-transition-review.mjs +47 -0
  369. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  370. package/packages/investigation/har-intake.mjs +68 -0
  371. package/packages/investigation/identity-event-intake.mjs +131 -0
  372. package/packages/investigation/identity-incident-review.mjs +39 -0
  373. package/packages/investigation/incident-sequence-review.mjs +58 -0
  374. package/packages/investigation/intake-report.mjs +19 -0
  375. package/packages/investigation/investigation-bagit.mjs +14 -0
  376. package/packages/investigation/investigation-capsule.mjs +33 -5
  377. package/packages/investigation/investigation-manifest.mjs +200 -0
  378. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  379. package/packages/investigation/investigation-package.mjs +200 -0
  380. package/packages/investigation/link-intake.mjs +179 -0
  381. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  382. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  383. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  384. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  385. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  386. package/packages/investigation/mail-authentication-review.mjs +222 -0
  387. package/packages/investigation/mail-header-identity.mjs +209 -0
  388. package/packages/investigation/message-intake.mjs +195 -0
  389. package/packages/investigation/page-behaviour.mjs +113 -0
  390. package/packages/investigation/pdf-intake.mjs +155 -0
  391. package/packages/investigation/platform-continuity-review.mjs +60 -0
  392. package/packages/investigation/qr-intake.mjs +56 -0
  393. package/packages/investigation/requested-action-hints.mjs +11 -0
  394. package/packages/investigation/selected-input-review.mjs +17 -0
  395. package/packages/investigation/storefront-review.mjs +70 -0
  396. package/packages/monitoring/analyst-review-state.mjs +38 -90
  397. package/packages/workspace/brand-profile-model.mjs +160 -22
  398. package/packages/workspace/bulk-columns.mjs +17 -0
  399. package/packages/workspace/bulk-review-model.mjs +39 -31
  400. package/packages/workspace/bulk-session-model.mjs +112 -27
  401. package/packages/workspace/bulk-sort.mjs +1 -0
  402. package/packages/workspace/campaign-model.mjs +1 -1
  403. package/packages/workspace/case-views.mjs +102 -0
  404. package/packages/workspace/detection-rule-model.mjs +1 -1
  405. package/packages/workspace/hostile-input.mjs +29 -8
  406. package/packages/workspace/investigation-guide.mjs +2 -2
  407. package/packages/workspace/investigation-template-model.mjs +45 -6
  408. package/packages/workspace/page-baseline.mjs +20 -17
  409. package/packages/workspace/relationship-observation-model.mjs +54 -35
  410. package/packages/workspace/shortlist-model.mjs +14 -5
  411. package/packages/workspace/watchlist-history.mjs +30 -14
  412. package/packages/workspace/watchlist-store.mjs +24 -6
  413. package/packages/workspace/website-snapshot-model.mjs +98 -33
  414. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  415. package/packages/workspace/workspace-archive.mjs +90 -25
  416. package/third-party-notices.txt +577 -17
  417. package/lib/hostname.mjs +0 -38
  418. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,11 +1,22 @@
1
+ import { renderCaseResponsePacket } from "./case-response-packet-render.mjs";
2
+ import { responseContactLabel as contactLabel } from "./case-response-packet-vocabulary.mjs";
1
3
  // Pure abuse-evidence packet builder. It creates local review artifacts only:
2
4
  // no network requests, mailto links, submissions, or provider side effects.
3
- import { caseDispositionSupportsDefensiveResponse } from "./case-record-operations.mjs";
4
- import { buildCaseActionOutcomeSummary, buildCaseResponseLifecycleSummary, CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_ASSERTION_STATES, CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, CASE_PIN_COMPLETENESS, CASE_PROVIDER_OUTCOMES, } from "./case-response-model.mjs";
5
- import { canonicalArtifactJsonV2, SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
6
- import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
7
- import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTIONS, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, MAX_RESPONSE_VALUE_LENGTH, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
8
- export { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, };
5
+ import { assertPacketAmendmentSelection } from "./case-requested-evidence.mjs";
6
+ import { assertBoundedJsonStructure, } from "../analysis/bounded-json.mjs";
7
+ import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
8
+ import { SORTED_JSON_V2, canonicalArtifactJsonV2, } from "../evidence/artifact-integrity.mjs";
9
+ import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
10
+ import { latestCaseEvidence, } from "./case-evidence-model.mjs";
11
+ import {} from "./case-model.mjs";
12
+ import { caseDispositionSupportsDefensiveResponse } from "./case-record-decisions.mjs";
13
+ import { buildCaseActionOutcomeSummary, buildCaseResponseLifecycleSummary, caseActionCompletesResponseDecision, countEvidenceLinkedCaseDecisions, } from "./case-response-model.mjs";
14
+ import { RESPONSE_AUTHORISATION_CONFIRMATION_IDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_STATES, } from "./case-response-packet-vocabulary.mjs";
15
+ import { validateCaseResponseReviewInputs, } from "./case-response-review-inputs.mjs";
16
+ import { responseRouteFreshness, } from "./response-route-freshness.mjs";
17
+ export * from "./case-response-packet-vocabulary.mjs";
18
+ export { validateCaseResponseReviewInputs, } from "./case-response-review-inputs.mjs";
19
+ export { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
9
20
  export const CASE_RESPONSE_PREFLIGHT_EVIDENCE_SCOPE = Object.freeze({
10
21
  version: 1,
11
22
  owner: 'case',
@@ -19,44 +30,8 @@ export const CASE_RESPONSE_PREFLIGHT_EVIDENCE_SCOPE = Object.freeze({
19
30
  'case_actions',
20
31
  ]),
21
32
  lookupDecisionFacts: 'unavailable',
22
- limitation: 'Lookup Decision Facts are transient and are not copied into browser-local cases. Case-response preflight evaluates only explicit case-owned records and analyst-entered incident context; it does not reconstruct Decision Facts from weaker saved fields.',
33
+ limitation: 'Lookup Decision Facts are transient and are not copied into saved Cases. Case-response preflight evaluates only explicit case-owned records and analyst-entered incident context; it does not reconstruct Decision Facts from weaker saved fields.',
23
34
  });
24
- export const RESPONSE_CONTACT_KINDS = [
25
- 'registrar',
26
- 'registry',
27
- 'network_hosting',
28
- 'security_txt',
29
- 'application_platform',
30
- ];
31
- export const RESPONSE_PACKET_PROFILE_IDS = [
32
- 'registrar',
33
- 'registry',
34
- 'network_hosting',
35
- 'security_contact',
36
- 'application_platform',
37
- 'browser_blocklist',
38
- 'internal_soc',
39
- ];
40
- export const RESPONSE_READINESS_STATES = ['complete', 'partial', 'stale', 'unavailable', 'not_provided'];
41
- export const RESPONSE_READINESS_ROW_IDS = [
42
- 'observed_behaviour',
43
- 'exact_url',
44
- 'observation_time',
45
- 'capture_provenance',
46
- 'infrastructure_responsibility',
47
- 'recipient_route',
48
- 'authority_review',
49
- 'selected_evidence',
50
- 'contradictions',
51
- 'source_limitations',
52
- ];
53
- export const RESPONSE_AUTHORISATION_CONFIRMATION_IDS = [
54
- 'selectedEvidence',
55
- 'recipientScope',
56
- 'privacyRedactions',
57
- 'analystAuthority',
58
- 'evidenceFreshness',
59
- ];
60
35
  export const RESPONSE_PACKET_PROFILES = Object.freeze([
61
36
  {
62
37
  id: 'registrar',
@@ -157,11 +132,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
157
132
  followUpFields: ['Internal owner', 'Due date', 'Decision', 'Control or escalation outcome'],
158
133
  },
159
134
  ]);
160
- const CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS);
161
135
  const RESPONSE_PROFILE_IDS = new Set(RESPONSE_PACKET_PROFILE_IDS);
162
- const PRE_PLATFORM_CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS.filter((kind) => kind !== 'application_platform'));
163
- const PRE_PLATFORM_PROFILE_IDS = new Set(RESPONSE_PACKET_PROFILE_IDS.filter((id) => id !== 'application_platform'));
164
- const PRE_PLATFORM_ACTION_TYPES = CASE_ACTION_TYPES.filter((type) => type !== 'platform_report');
165
136
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
166
137
  const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
167
138
  function text(value, maximum) {
@@ -173,10 +144,7 @@ function compareCodeUnits(left, right) {
173
144
  return left < right ? -1 : left > right ? 1 : 0;
174
145
  }
175
146
  function timestamp(value) {
176
- if (typeof value !== 'string' || value.length > 64 || CONTROL_RE.test(value))
177
- return null;
178
- const parsed = Date.parse(value);
179
- return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
147
+ return normalizeExplicitIsoTimestamp(value);
180
148
  }
181
149
  function normalizeExactUrl(value, rejectOverlong) {
182
150
  if (typeof value !== 'string' || CONTROL_RE.test(value))
@@ -244,23 +212,17 @@ function actionContactKind(type) {
244
212
  return 'application_platform';
245
213
  return 'manual';
246
214
  }
247
- function routeFreshness(observedAt, reviewAfter, generatedAt) {
248
- if (!observedAt)
249
- return 'unknown';
250
- const routeAge = Date.parse(generatedAt) - Date.parse(observedAt);
251
- const reviewExpired = reviewAfter !== null && Date.parse(generatedAt) >= Date.parse(reviewAfter);
252
- return routeAge < -MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS
253
- || reviewExpired
254
- || (reviewAfter === null && routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000)
255
- ? 'stale'
256
- : 'current';
257
- }
258
215
  function selectedPacketAction(caseRecord, input) {
259
216
  const actionId = typeof input.actionId === 'string' && /^[A-Za-z0-9_-]{1,64}$/u.test(input.actionId)
260
217
  ? input.actionId
261
218
  : null;
262
219
  return actionId ? caseRecord.actions.find((action) => action.id === actionId) ?? null : null;
263
220
  }
221
+ function assertAmendmentEvidence(caseRecord, input) {
222
+ const action = selectedPacketAction(caseRecord, input);
223
+ const selected = new Set(Array.isArray(input.selectedEvidencePinIds) ? input.selectedEvidencePinIds : []);
224
+ assertPacketAmendmentSelection(caseRecord.actions, action?.id ?? null, new Set(caseRecord.evidencePins.filter(pin => selected.has(pin.id)).map(pin => pin.id)));
225
+ }
264
226
  function packetActionLineage(caseRecord, input) {
265
227
  const selected = selectedPacketAction(caseRecord, input);
266
228
  if (!selected)
@@ -321,7 +283,7 @@ function bindPacketRoute(caseRecord, input, generatedAt) {
321
283
  source: text(action.contactSource, 120) || 'analyst supplied',
322
284
  observedAt,
323
285
  reviewAfter,
324
- freshness: routeFreshness(observedAt, reviewAfter, generatedAt),
286
+ freshness: responseRouteFreshness(observedAt, reviewAfter, generatedAt),
325
287
  limitations: normalizeLimitations(action.contactLimitations),
326
288
  };
327
289
  const contacts = kind === 'manual' ? [] : [{
@@ -359,7 +321,9 @@ function normalizeSelectedEvidence(caseRecord, value) {
359
321
  id: pin.id,
360
322
  label: text(pin.label, 80),
361
323
  source: text(pin.source, 120),
362
- observedAt: timestamp(pin.observedAt) ?? caseRecord.updatedAt,
324
+ ...(pin.observationHostname ? { observationHostname: pin.observationHostname } : {}),
325
+ ...(pin.webObservationMode ? { webObservationMode: pin.webObservationMode } : {}),
326
+ observedAt: timestamp(pin.observedAt),
363
327
  completeness: text(pin.completeness, 40),
364
328
  limitations: normalizeLimitations(pin.limitations),
365
329
  }));
@@ -392,10 +356,10 @@ function readinessOverride(value, key) {
392
356
  limitations: normalizeLimitations(item.limitations),
393
357
  };
394
358
  }
395
- function buildResponseReadiness(caseRecord, input, generatedAt, contacts, recipientRoute, selectedEvidence, contradictions) {
359
+ function buildResponseReadiness(input, generatedAt, contacts, recipientRoute, selectedEvidence, contradictions) {
396
360
  const profile = responsePacketProfile(input.profile);
397
361
  const urls = normalizeUrls(input.abusiveUrls);
398
- const observedAt = timestamp(input.observedAt) || caseRecord.evidenceHistory.at(-1)?.capturedAt || null;
362
+ const observedAt = timestamp(input.observedAt);
399
363
  const age = observedAt ? observationAge(observedAt, generatedAt) : null;
400
364
  const infrastructure = readinessOverride(input.readiness, 'infrastructureResponsibility');
401
365
  const authority = readinessOverride(input.readiness, 'authorityReview');
@@ -426,7 +390,10 @@ function buildResponseReadiness(caseRecord, input, generatedAt, contacts, recipi
426
390
  {
427
391
  id: 'capture_provenance', label: 'Capture provenance',
428
392
  state: !selectedEvidence.length ? 'unavailable' : selectedEvidence.every((item) => item.source && item.observedAt) ? 'complete' : 'partial',
429
- detail: selectedEvidence.length ? 'Selected evidence retains source and observation metadata.' : 'No selected evidence is available for capture-provenance review.',
393
+ detail: !selectedEvidence.length ? 'No selected evidence is available for capture-provenance review.'
394
+ : selectedEvidence.every((item) => item.source && item.observedAt)
395
+ ? 'Selected evidence retains source and observation metadata.'
396
+ : 'Selected evidence is retained, but some source or observation-time metadata is unavailable.',
430
397
  requiredForAuthorisation: true,
431
398
  limitations: selectedEvidence.flatMap((item) => item.limitations).slice(0, 8),
432
399
  },
@@ -496,7 +463,7 @@ export function buildCaseResponseReadiness(caseRecord, input, generatedAt = new
496
463
  const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
497
464
  const binding = bindPacketRoute(caseRecord, input, normalizedGeneratedAt);
498
465
  const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
499
- return buildResponseReadiness(caseRecord, input, normalizedGeneratedAt, binding.contacts, binding.recipientRoute, selectedEvidence, normalizeContradictions(caseRecord));
466
+ return buildResponseReadiness(input, normalizedGeneratedAt, binding.contacts, binding.recipientRoute, selectedEvidence, normalizeContradictions(caseRecord));
500
467
  }
501
468
  function normalizeArtefactReferences(value) {
502
469
  if (!Array.isArray(value))
@@ -543,7 +510,7 @@ export function buildResponsePacketProfilePreview(caseRecord, input) {
543
510
  const binding = bindPacketRoute(caseRecord, input, caseRecord.updatedAt);
544
511
  const missingEvidence = [
545
512
  ...(!normalizeUrls(input.abusiveUrls).length ? ['At least one exact HTTP(S) URL'] : []),
546
- ...(!timestamp(input.observedAt) && !caseRecord.evidenceHistory.at(-1)?.capturedAt ? ['Observation time'] : []),
513
+ ...(!timestamp(input.observedAt) ? ['Observation time'] : []),
547
514
  ...(!normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds).length ? ['Explicitly selected evidence pin'] : []),
548
515
  ...(profile.id !== 'internal_soc' && binding.actionBinding.state !== 'selected'
549
516
  ? ['Selected retained Case action']
@@ -569,10 +536,16 @@ export function buildResponsePacketProfilePreview(caseRecord, input) {
569
536
  };
570
537
  }
571
538
  export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new Date().toISOString()) {
572
- const latestEvidence = caseRecord.evidenceHistory.at(-1) ?? null;
573
- const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
539
+ const observedAt = timestamp(input.observedAt);
574
540
  const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
575
541
  const binding = bindPacketRoute(caseRecord, input, normalizedGeneratedAt);
542
+ let amendmentError = null;
543
+ try {
544
+ assertAmendmentEvidence(caseRecord, input);
545
+ }
546
+ catch (error) {
547
+ amendmentError = error instanceof Error ? error.message : 'Review this amendment and its retained evidence.';
548
+ }
576
549
  const urls = normalizeUrls(input.abusiveUrls);
577
550
  const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
578
551
  const requiredComplete = Boolean(text(input.category, MAX_ABUSE_CATEGORY_LENGTH)
@@ -585,10 +558,9 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
585
558
  .filter((item) => item.kind === 'contradiction' && item.state === 'open')
586
559
  .length;
587
560
  const actionSummary = buildCaseActionOutcomeSummary(caseRecord.actions, normalizedGeneratedAt);
588
- const retainedPinIds = new Set(caseRecord.evidencePins.map((pin) => pin.id));
589
- const evidenceLinkedDecisionCount = caseRecord.decisions.filter((decision) => decision.evidencePinIds.some((evidencePinId) => retainedPinIds.has(evidencePinId))).length;
561
+ const evidenceLinkedDecisionCount = countEvidenceLinkedCaseDecisions(caseRecord.decisions, caseRecord.evidencePins);
590
562
  const responseDisposition = caseDispositionSupportsDefensiveResponse(caseRecord.disposition);
591
- const reviewedActionCount = caseRecord.actions.filter((action) => ['reviewed', 'authorised', 'submitted', 'acknowledged', 'terminal'].includes(action.state)).length;
563
+ const reviewedActionCount = caseRecord.actions.filter((action) => caseActionCompletesResponseDecision(action.state)).length;
592
564
  const profile = responsePacketProfile(input.profile);
593
565
  const checks = [
594
566
  {
@@ -676,10 +648,10 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
676
648
  {
677
649
  id: 'packet_action',
678
650
  label: 'Packet action',
679
- state: binding.actionBinding.state === 'selected' ? 'pass' : profile.id === 'internal_soc' ? 'caution' : 'block',
680
- detail: binding.actionBinding.state === 'selected'
651
+ state: amendmentError ? 'block' : binding.actionBinding.state === 'selected' ? 'pass' : profile.id === 'internal_soc' ? 'caution' : 'block',
652
+ detail: amendmentError ?? (binding.actionBinding.state === 'selected'
681
653
  ? `Action ${binding.actionBinding.selectedActionId} owns this packet and its retained origin lineage.`
682
- : 'Select the retained Case action this packet prepares or documents.',
654
+ : 'Select the retained Case action this packet prepares or documents.'),
683
655
  },
684
656
  {
685
657
  id: 'action_tracking',
@@ -714,6 +686,7 @@ function normalizeActionHistory(caseRecord, input) {
714
686
  .map((action) => ({
715
687
  actionId: action.id,
716
688
  type: text(action.type, 80),
689
+ ...(action.amendment ? { amendment: structuredClone(action.amendment) } : {}),
717
690
  recipient: text(action.recipient, 320),
718
691
  contactSource: text(action.contactSource, 120),
719
692
  routeObservedAt: timestamp(action.routeObservedAt),
@@ -727,6 +700,7 @@ function normalizeActionHistory(caseRecord, input) {
727
700
  historyLimitations: normalizeLimitations(action.historyLimitations),
728
701
  transitions: action.history.map((event) => ({
729
702
  id: event.id,
703
+ ...(event.evidenceRequest ? { evidenceRequest: structuredClone(event.evidenceRequest) } : {}),
730
704
  previousState: event.previousState,
731
705
  nextState: event.nextState,
732
706
  occurredAt: event.occurredAt,
@@ -788,6 +762,7 @@ function normalizeResponseLifecycle(caseRecord, actionIds, lineageComplete) {
788
762
  'Provider workflow outcomes and independently observed technical effects are separate point-in-time records.',
789
763
  'A provider acknowledgement, terminal state, or reported resolution never becomes independently observed remediation, absence, or safety.',
790
764
  'Times are withheld when the corresponding typed event is missing or ambiguous.',
765
+ ...(!summary.latestObservedEffect && scopedRecord.observedEffects.reviews.length ? ['A single latest independent review cannot be selected from the retained observation times; all retained reviews remain separately attributed.'] : []),
791
766
  ...(!lineageComplete ? ['Provider-outcome time is withheld because the selected action origin lineage is incomplete.'] : []),
792
767
  ],
793
768
  };
@@ -811,12 +786,13 @@ async function sha256(value) {
811
786
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
812
787
  }
813
788
  export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
789
+ assertAmendmentEvidence(caseRecord, input);
814
790
  const profile = buildResponsePacketProfilePreview(caseRecord, input);
815
791
  const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH);
816
792
  const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
817
793
  const contradictions = normalizeContradictions(caseRecord);
818
794
  const binding = bindPacketRoute(caseRecord, input, generatedAt);
819
- const readiness = buildResponseReadiness(caseRecord, input, generatedAt, binding.contacts, binding.recipientRoute, selectedEvidence, contradictions);
795
+ const readiness = buildResponseReadiness(input, generatedAt, binding.contacts, binding.recipientRoute, selectedEvidence, contradictions);
820
796
  const escalation = normalizeActionHistory(caseRecord, input);
821
797
  return {
822
798
  contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
@@ -843,7 +819,7 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
843
819
  affectedParty: text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH),
844
820
  abusiveUrls: normalizeUrls(input.abusiveUrls, true),
845
821
  observedHarm: text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH),
846
- observedAt: timestamp(input.observedAt) || caseRecord.evidenceHistory.at(-1)?.capturedAt || null,
822
+ observedAt: timestamp(input.observedAt),
847
823
  },
848
824
  contacts: binding.contacts,
849
825
  recipientRoute: binding.recipientRoute,
@@ -858,359 +834,6 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
858
834
  responseLifecycle: normalizeResponseLifecycle(caseRecord, escalation.actionBinding.lineageActionIds, escalation.lineageComplete),
859
835
  };
860
836
  }
861
- const CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze([
862
- 'contract',
863
- 'version',
864
- 'profile',
865
- 'case',
866
- 'incident',
867
- 'contacts',
868
- 'recipientRoute',
869
- 'actionBinding',
870
- 'selectedEvidence',
871
- 'contradictions',
872
- 'readiness',
873
- 'artefactReferences',
874
- 'escalationHistory',
875
- 'escalationHistoryOmitted',
876
- 'escalationHistoryLimitations',
877
- 'responseLifecycle',
878
- ]);
879
- const PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze(CASE_RESPONSE_REVIEW_INPUT_KEYS.filter((key) => key !== 'recipientRoute' && key !== 'actionBinding'));
880
- function exactReviewRecord(value, keys, label) {
881
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
882
- throw new TypeError(`${label} must be an exact object.`);
883
- }
884
- const ownKeys = Reflect.ownKeys(value);
885
- if (ownKeys.length !== keys.length
886
- || ownKeys.some((key) => typeof key !== 'string' || !keys.includes(key))) {
887
- throw new TypeError(`${label} contains an undeclared field.`);
888
- }
889
- const descriptors = Object.getOwnPropertyDescriptors(value);
890
- if (keys.some((key) => !descriptors[key] || !Object.hasOwn(descriptors[key], 'value'))) {
891
- throw new TypeError(`${label} must not contain accessors.`);
892
- }
893
- return value;
894
- }
895
- function boundedReviewArray(value, maximum, label) {
896
- if (!Array.isArray(value) || value.length > maximum) {
897
- throw new TypeError(`${label} exceeds its declared array bound.`);
898
- }
899
- const ownKeys = Reflect.ownKeys(value).filter((key) => key !== 'length');
900
- if (ownKeys.length !== value.length
901
- || ownKeys.some((key, index) => key !== String(index))) {
902
- throw new TypeError(`${label} must be a dense ordinary array.`);
903
- }
904
- const descriptors = Object.getOwnPropertyDescriptors(value);
905
- if (ownKeys.some((key) => typeof key !== 'string' || !Object.hasOwn(descriptors[key], 'value'))) {
906
- throw new TypeError(`${label} must not contain accessors.`);
907
- }
908
- return value;
909
- }
910
- function reviewText(value, maximum, label, nullable = false) {
911
- if (nullable && value === null)
912
- return null;
913
- if (typeof value !== 'string' || value.length > maximum || CONTROL_RE.test(value)) {
914
- throw new TypeError(`${label} is not a bounded text value.`);
915
- }
916
- return value;
917
- }
918
- function reviewStrings(value, maximum, itemMaximum, label) {
919
- const items = boundedReviewArray(value, maximum, label);
920
- for (const item of items)
921
- reviewText(item, itemMaximum, `${label} item`);
922
- return items;
923
- }
924
- function reviewCount(value, maximum, label) {
925
- if (!Number.isSafeInteger(value) || value < 0 || value > maximum) {
926
- throw new TypeError(`${label} is not a bounded count.`);
927
- }
928
- return value;
929
- }
930
- function reviewEnum(value, values, label) {
931
- if (typeof value !== 'string' || !values.includes(value)) {
932
- throw new TypeError(`${label} contains an unsupported value.`);
933
- }
934
- return value;
935
- }
936
- function reviewNullableEnum(value, values, label) {
937
- return value === null ? null : reviewEnum(value, values, label);
938
- }
939
- function validateReviewLifecycle(value) {
940
- const lifecycle = exactReviewRecord(value, [
941
- 'providerOutcomeState', 'latestProviderOutcome', 'observedChangeState',
942
- 'latestObservedEffect', 'latestObservedChangeAt', 'closure', 'limitations',
943
- ], 'Case-response review lifecycle');
944
- reviewEnum(lifecycle.providerOutcomeState, ['available', 'missing', 'ambiguous'], 'Case-response provider-outcome state');
945
- reviewEnum(lifecycle.observedChangeState, ['available', 'missing', 'ambiguous'], 'Case-response observed-change state');
946
- reviewText(lifecycle.latestObservedChangeAt, 64, 'Case-response latest observed change', true);
947
- reviewStrings(lifecycle.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response lifecycle limitations');
948
- if (lifecycle.latestProviderOutcome !== null) {
949
- const outcome = exactReviewRecord(lifecycle.latestProviderOutcome, [
950
- 'actionId', 'eventId', 'outcome', 'occurredAt', 'reference',
951
- ], 'Case-response latest provider outcome');
952
- reviewText(outcome.actionId, 64, 'Case-response provider action id');
953
- reviewText(outcome.eventId, 64, 'Case-response provider event id');
954
- reviewEnum(outcome.outcome, CASE_PROVIDER_OUTCOMES, 'Case-response provider outcome');
955
- reviewText(outcome.occurredAt, 64, 'Case-response provider outcome time');
956
- reviewText(outcome.reference, MAX_RESPONSE_REFERENCE_LENGTH, 'Case-response provider reference', true);
957
- }
958
- if (lifecycle.latestObservedEffect !== null) {
959
- const effect = exactReviewRecord(lifecycle.latestObservedEffect, [
960
- 'reviewId', 'state', 'observedAt', 'sourceClass', 'source',
961
- ], 'Case-response latest observed effect');
962
- reviewText(effect.reviewId, 64, 'Case-response observed-effect review id');
963
- reviewEnum(effect.state, CASE_OBSERVED_EFFECT_STATES, 'Case-response observed-effect state');
964
- reviewText(effect.observedAt, 64, 'Case-response observed-effect time');
965
- reviewEnum(effect.sourceClass, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, 'Case-response observed-effect source class');
966
- reviewText(effect.source, MAX_RESPONSE_VALUE_LENGTH, 'Case-response observed-effect source');
967
- }
968
- if (lifecycle.closure !== null) {
969
- const closure = exactReviewRecord(lifecycle.closure, [
970
- 'id', 'reason', 'createdAt', 'limitations',
971
- ], 'Case-response latest closure');
972
- reviewText(closure.id, 64, 'Case-response closure id');
973
- reviewEnum(closure.reason, CASE_CLOSURE_REASONS, 'Case-response closure reason');
974
- reviewText(closure.createdAt, 64, 'Case-response closure time');
975
- reviewStrings(closure.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response closure limitations');
976
- }
977
- }
978
- function recursivelyFreezeReviewValue(value) {
979
- if (!value || typeof value !== 'object' || Object.isFrozen(value))
980
- return value;
981
- for (const child of Object.values(value)) {
982
- recursivelyFreezeReviewValue(child);
983
- }
984
- return Object.freeze(value);
985
- }
986
- export function validateCaseResponseReviewInputs(value) {
987
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
988
- throw new TypeError('Case-response review inputs must be an exact object.');
989
- }
990
- const descriptors = Object.getOwnPropertyDescriptors(value);
991
- if (!descriptors.contract || !Object.hasOwn(descriptors.contract, 'value')
992
- || !descriptors.version || !Object.hasOwn(descriptors.version, 'value')) {
993
- throw new TypeError('Case-response review inputs must not contain accessors.');
994
- }
995
- const version = descriptors.version.value;
996
- const current = version === CASE_RESPONSE_REVIEW_INPUTS_VERSION;
997
- const hasActionBinding = version === LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION || current;
998
- const publishedV2 = version === PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION;
999
- if (descriptors.contract.value !== CASE_RESPONSE_REVIEW_INPUTS_SCHEMA || (!current && !hasActionBinding && !publishedV2)) {
1000
- throw new TypeError('Case-response review inputs contain an unsupported version, shape, or bound.');
1001
- }
1002
- const source = exactReviewRecord(value, hasActionBinding ? CASE_RESPONSE_REVIEW_INPUT_KEYS : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS, 'Case-response review inputs');
1003
- const profile = exactReviewRecord(source.profile, [
1004
- 'id', 'label', 'audience', 'subject', 'checklist', 'includedEvidence',
1005
- 'excludedEvidence', 'redactions',
1006
- ], 'Case-response review profile');
1007
- const supportedProfileIds = current ? RESPONSE_PROFILE_IDS : PRE_PLATFORM_PROFILE_IDS;
1008
- if (typeof profile.id !== 'string' || !supportedProfileIds.has(profile.id)) {
1009
- throw new TypeError('Case-response review profile is unsupported.');
1010
- }
1011
- for (const key of ['label', 'audience', 'subject']) {
1012
- reviewText(profile[key], 500, `Case-response profile ${key}`);
1013
- }
1014
- for (const key of ['checklist', 'includedEvidence', 'excludedEvidence', 'redactions']) {
1015
- reviewStrings(profile[key], 32, 500, `Case-response profile ${key}`);
1016
- }
1017
- const caseRecord = exactReviewRecord(source.case, [
1018
- 'id', 'domain', 'status', 'disposition', 'updatedAt',
1019
- ], 'Case-response review Case');
1020
- reviewText(caseRecord.id, 64, 'Case-response Case id');
1021
- reviewText(caseRecord.domain, 253, 'Case-response Case domain');
1022
- reviewText(caseRecord.status, 64, 'Case-response Case status');
1023
- reviewText(caseRecord.disposition, 64, 'Case-response Case disposition');
1024
- reviewText(caseRecord.updatedAt, 64, 'Case-response Case update time');
1025
- const incident = exactReviewRecord(source.incident, [
1026
- 'category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt',
1027
- ], 'Case-response review incident');
1028
- reviewText(incident.category, MAX_ABUSE_CATEGORY_LENGTH, 'Case-response category');
1029
- reviewText(incident.affectedParty, MAX_AFFECTED_PARTY_LENGTH, 'Case-response affected party');
1030
- reviewStrings(incident.abusiveUrls, MAX_ABUSIVE_URLS, MAX_EXACT_URL_LENGTH, 'Case-response abusive URLs');
1031
- reviewText(incident.observedHarm, MAX_RESPONSE_HARM_LENGTH, 'Case-response observed harm');
1032
- reviewText(incident.observedAt, 64, 'Case-response observation time', true);
1033
- for (const candidate of boundedReviewArray(source.contacts, MAX_RESPONSE_CONTACTS, 'Case-response contacts')) {
1034
- const contact = exactReviewRecord(candidate, [
1035
- 'kind', 'contact', 'source', 'observedAt', ...(current ? ['reviewAfter'] : []), 'freshness', 'limitations',
1036
- ], 'Case-response contact');
1037
- const supportedContactKinds = current ? CONTACT_KINDS : PRE_PLATFORM_CONTACT_KINDS;
1038
- if (typeof contact.kind !== 'string' || !supportedContactKinds.has(contact.kind)
1039
- || typeof contact.freshness !== 'string'
1040
- || !['current', 'stale', 'unknown'].includes(contact.freshness)) {
1041
- throw new TypeError('Case-response contact contains an unsupported enum.');
1042
- }
1043
- reviewText(contact.contact, 320, 'Case-response contact value');
1044
- reviewText(contact.source, 120, 'Case-response contact source');
1045
- reviewText(contact.observedAt, 64, 'Case-response contact observation time', true);
1046
- if (current)
1047
- reviewText(contact.reviewAfter, 64, 'Case-response contact review deadline', true);
1048
- reviewStrings(contact.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contact limitations');
1049
- }
1050
- if (hasActionBinding && source.recipientRoute !== null) {
1051
- const route = exactReviewRecord(source.recipientRoute, [
1052
- 'actionId', 'kind', 'contact', 'source', 'observedAt', ...(current ? ['reviewAfter'] : []), 'freshness', 'limitations',
1053
- ], 'Case-response recipient route');
1054
- reviewText(route.actionId, 64, 'Case-response recipient action id');
1055
- reviewEnum(route.kind, [...(current ? RESPONSE_CONTACT_KINDS : [...PRE_PLATFORM_CONTACT_KINDS]), 'manual'], 'Case-response recipient kind');
1056
- reviewText(route.contact, 320, 'Case-response recipient value');
1057
- reviewText(route.source, 120, 'Case-response recipient source');
1058
- reviewText(route.observedAt, 64, 'Case-response recipient observation time', true);
1059
- if (current)
1060
- reviewText(route.reviewAfter, 64, 'Case-response recipient review deadline', true);
1061
- reviewEnum(route.freshness, ['current', 'stale', 'unknown'], 'Case-response recipient freshness');
1062
- reviewStrings(route.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response recipient limitations');
1063
- }
1064
- let currentLineageActionIds = null;
1065
- if (hasActionBinding) {
1066
- const actionBinding = exactReviewRecord(source.actionBinding, [
1067
- 'state', 'selectedActionId', 'lineageActionIds', 'limitations',
1068
- ], 'Case-response action binding');
1069
- const bindingState = reviewEnum(actionBinding.state, ['selected', 'not_selected'], 'Case-response action binding state');
1070
- const selectedActionId = reviewText(actionBinding.selectedActionId, 64, 'Case-response selected action id', true);
1071
- const lineageActionIds = reviewStrings(actionBinding.lineageActionIds, MAX_RESPONSE_ACTION_HISTORY, 64, 'Case-response action lineage');
1072
- currentLineageActionIds = lineageActionIds;
1073
- if ((bindingState === 'selected') !== Boolean(selectedActionId)
1074
- || (selectedActionId !== null && lineageActionIds[0] !== selectedActionId)
1075
- || new Set(lineageActionIds).size !== lineageActionIds.length) {
1076
- throw new TypeError('Case-response action binding is inconsistent.');
1077
- }
1078
- reviewStrings(actionBinding.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response action-binding limitations');
1079
- if (source.recipientRoute !== null
1080
- && source.recipientRoute.actionId !== selectedActionId) {
1081
- throw new TypeError('Case-response recipient route is not bound to the selected action.');
1082
- }
1083
- }
1084
- for (const candidate of boundedReviewArray(source.selectedEvidence, MAX_RESPONSE_SELECTED_EVIDENCE, 'Case-response selected evidence')) {
1085
- const evidence = exactReviewRecord(candidate, [
1086
- 'id', 'label', 'source', 'observedAt', 'completeness', 'limitations',
1087
- ], 'Case-response selected evidence item');
1088
- reviewText(evidence.id, 64, 'Case-response evidence id');
1089
- reviewText(evidence.label, 80, 'Case-response evidence label');
1090
- reviewText(evidence.source, 120, 'Case-response evidence source');
1091
- reviewText(evidence.observedAt, 64, 'Case-response evidence observation time');
1092
- reviewEnum(evidence.completeness, CASE_PIN_COMPLETENESS, 'Case-response evidence completeness');
1093
- reviewStrings(evidence.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response evidence limitations');
1094
- }
1095
- for (const candidate of boundedReviewArray(source.contradictions, MAX_RESPONSE_CONTRADICTIONS, 'Case-response contradictions')) {
1096
- const contradiction = exactReviewRecord(candidate, [
1097
- 'id', 'statement', 'state', 'limitations',
1098
- ], 'Case-response contradiction');
1099
- reviewText(contradiction.id, 64, 'Case-response contradiction id');
1100
- reviewText(contradiction.statement, 2_000, 'Case-response contradiction statement');
1101
- reviewEnum(contradiction.state, CASE_ASSERTION_STATES, 'Case-response contradiction state');
1102
- reviewStrings(contradiction.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contradiction limitations');
1103
- }
1104
- const readiness = exactReviewRecord(source.readiness, [
1105
- 'profileId', 'rows', 'counts', 'limitations',
1106
- ], 'Case-response readiness');
1107
- if (typeof readiness.profileId !== 'string' || !supportedProfileIds.has(readiness.profileId)) {
1108
- throw new TypeError('Case-response readiness profile is unsupported.');
1109
- }
1110
- const readinessRows = boundedReviewArray(readiness.rows, RESPONSE_READINESS_ROW_IDS.length, 'Case-response readiness rows');
1111
- const readinessIds = new Set();
1112
- const readinessCounts = Object.fromEntries(RESPONSE_READINESS_STATES.map((state) => [state, 0]));
1113
- for (const candidate of readinessRows) {
1114
- const row = exactReviewRecord(candidate, [
1115
- 'id', 'label', 'state', 'detail', 'requiredForAuthorisation', 'limitations',
1116
- ], 'Case-response readiness row');
1117
- if (typeof row.id !== 'string'
1118
- || !RESPONSE_READINESS_ROW_IDS.includes(row.id)
1119
- || readinessIds.has(row.id)
1120
- || typeof row.state !== 'string'
1121
- || !RESPONSE_READINESS_STATES.includes(row.state)
1122
- || typeof row.requiredForAuthorisation !== 'boolean') {
1123
- throw new TypeError('Case-response readiness row is unsupported or duplicated.');
1124
- }
1125
- readinessIds.add(row.id);
1126
- readinessCounts[row.state] += 1;
1127
- reviewText(row.label, 120, 'Case-response readiness label');
1128
- reviewText(row.detail, 500, 'Case-response readiness detail');
1129
- reviewStrings(row.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response readiness limitations');
1130
- }
1131
- const counts = exactReviewRecord(readiness.counts, RESPONSE_READINESS_STATES, 'Case-response readiness counts');
1132
- for (const state of RESPONSE_READINESS_STATES) {
1133
- if (reviewCount(counts[state], RESPONSE_READINESS_ROW_IDS.length, `Case-response readiness ${state} count`) !== readinessCounts[state]) {
1134
- throw new TypeError('Case-response readiness counts do not match the exact rows.');
1135
- }
1136
- }
1137
- reviewStrings(readiness.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response readiness limitations');
1138
- for (const candidate of boundedReviewArray(source.artefactReferences, MAX_RESPONSE_ARTEFACT_REFERENCES, 'Case-response artefact references')) {
1139
- const reference = exactReviewRecord(candidate, [
1140
- 'id', 'label', 'mediaType', 'capturedAt', 'source', 'digestSha256',
1141
- 'byteLength', 'limitations',
1142
- ], 'Case-response artefact reference');
1143
- reviewText(reference.id, 64, 'Case-response artefact id');
1144
- reviewText(reference.label, 120, 'Case-response artefact label');
1145
- reviewText(reference.mediaType, 120, 'Case-response artefact media type');
1146
- reviewText(reference.capturedAt, 64, 'Case-response artefact capture time');
1147
- reviewText(reference.source, 120, 'Case-response artefact source');
1148
- if (typeof reference.digestSha256 !== 'string' || !/^[a-f0-9]{64}$/u.test(reference.digestSha256)) {
1149
- throw new TypeError('Case-response artefact digest is invalid.');
1150
- }
1151
- if (reference.byteLength !== null)
1152
- reviewCount(reference.byteLength, 100 * 1024 * 1024, 'Case-response artefact byte length');
1153
- reviewStrings(reference.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response artefact limitations');
1154
- }
1155
- const escalationActionIds = [];
1156
- for (const candidate of boundedReviewArray(source.escalationHistory, MAX_RESPONSE_ACTION_HISTORY, 'Case-response escalation history')) {
1157
- const action = exactReviewRecord(candidate, [
1158
- 'actionId', 'type', 'recipient', 'contactSource', 'state', 'reference',
1159
- ...(hasActionBinding ? ['routeObservedAt'] : []),
1160
- ...(current ? ['routeReviewAfter'] : []),
1161
- 'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
1162
- 'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
1163
- ], 'Case-response escalation action');
1164
- for (const key of ['actionId', 'recipient', 'contactSource', 'createdAt', 'updatedAt']) {
1165
- reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`);
1166
- }
1167
- if (escalationActionIds.includes(action.actionId)) {
1168
- throw new TypeError('Case-response escalation action identities must be unique.');
1169
- }
1170
- escalationActionIds.push(action.actionId);
1171
- if (hasActionBinding)
1172
- reviewText(action.routeObservedAt, 64, 'Case-response action route observation time', true);
1173
- if (current)
1174
- reviewText(action.routeReviewAfter, 64, 'Case-response action route review deadline', true);
1175
- reviewEnum(action.type, current ? CASE_ACTION_TYPES : PRE_PLATFORM_ACTION_TYPES, 'Case-response action type');
1176
- reviewEnum(action.state, CASE_ACTION_STATES, 'Case-response action state');
1177
- for (const key of ['reference', 'outcomeDetail', 'originActionId']) {
1178
- reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`, true);
1179
- }
1180
- reviewNullableEnum(action.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response action provider outcome');
1181
- reviewCount(action.historyOmitted, MAX_CASE_ACTION_EVENTS_PER_CASE, 'Case-response omitted transition count');
1182
- reviewStrings(action.historyLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response action history limitations');
1183
- for (const candidateTransition of boundedReviewArray(action.transitions, MAX_CASE_ACTION_EVENTS_PER_ACTION, 'Case-response transitions')) {
1184
- const transition = exactReviewRecord(candidateTransition, [
1185
- 'id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance',
1186
- 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail',
1187
- 'originActionId', 'applied',
1188
- ], 'Case-response transition');
1189
- for (const key of ['id', 'occurredAt', 'provenance']) {
1190
- reviewText(transition[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response transition ${key}`);
1191
- }
1192
- reviewNullableEnum(transition.previousState, CASE_ACTION_STATES, 'Case-response previous action state');
1193
- reviewEnum(transition.nextState, CASE_ACTION_STATES, 'Case-response next action state');
1194
- reviewEnum(transition.sourceClass, CASE_ACTION_EVENT_SOURCE_CLASSES, 'Case-response transition source class');
1195
- for (const key of ['reference', 'evidencePinId', 'outcomeDetail', 'originActionId']) {
1196
- reviewText(transition[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response transition ${key}`, true);
1197
- }
1198
- reviewNullableEnum(transition.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response transition provider outcome');
1199
- if (typeof transition.applied !== 'boolean')
1200
- throw new TypeError('Case-response transition applied state is invalid.');
1201
- reviewStrings(transition.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response transition limitations');
1202
- }
1203
- }
1204
- if (currentLineageActionIds
1205
- && (currentLineageActionIds.length !== escalationActionIds.length
1206
- || escalationActionIds.some((id, index) => currentLineageActionIds?.[index] !== id))) {
1207
- throw new TypeError('Case-response escalation history must match the selected action lineage in order.');
1208
- }
1209
- reviewCount(source.escalationHistoryOmitted, MAX_CASE_ACTIONS, 'Case-response omitted action count');
1210
- reviewStrings(source.escalationHistoryLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response escalation limitations');
1211
- validateReviewLifecycle(source.responseLifecycle);
1212
- return recursivelyFreezeReviewValue(structuredClone(source));
1213
- }
1214
837
  export async function buildCaseResponseReviewDigest(caseRecord, input, generatedAt = new Date().toISOString()) {
1215
838
  const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
1216
839
  return sha256(canonicalArtifactJsonV2(validateCaseResponseReviewInputs(buildCaseResponseReviewInputs(caseRecord, input, normalizedGeneratedAt))));
@@ -1280,27 +903,13 @@ export async function verifyCaseResponsePacketIntegrity(packet) {
1280
903
  }
1281
904
  return integrity.digestSha256 === await sha256(canonicalArtifactJsonV2(unsigned));
1282
905
  }
1283
- function escapeMarkdown(value) {
1284
- return value.replace(/([\\`*_[\]<>|])/gu, '\\$1').replace(/\r?\n/gu, ' ');
1285
- }
1286
- function contactLabel(value) {
1287
- if (value === 'network_hosting')
1288
- return 'Observed endpoint network registration';
1289
- if (value === 'security_txt')
1290
- return 'security.txt';
1291
- if (value === 'application_platform')
1292
- return 'Application platform';
1293
- if (value === 'manual')
1294
- return 'Manual submission';
1295
- return value.charAt(0).toUpperCase() + value.slice(1);
1296
- }
1297
906
  export async function buildCaseResponsePacket(caseRecord, input, generatedAt = new Date().toISOString()) {
1298
907
  const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH);
1299
908
  const affectedParty = text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH);
1300
909
  const abusiveUrls = normalizeUrls(input.abusiveUrls, true);
1301
910
  const observedHarm = text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH);
1302
- const latestEvidence = caseRecord.evidenceHistory.at(-1) ?? null;
1303
- const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
911
+ const latestEvidence = latestCaseEvidence(caseRecord);
912
+ const observedAt = timestamp(input.observedAt);
1304
913
  if (!category || !affectedParty || !abusiveUrls.length || !observedHarm || !observedAt) {
1305
914
  throw new Error('Category, affected party, at least one exact HTTP(S) URL, observed harm, and an observation time are required.');
1306
915
  }
@@ -1395,151 +1004,7 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
1395
1004
  digestSha256,
1396
1005
  },
1397
1006
  };
1398
- const lines = [
1399
- `# ${escapeMarkdown(profile.label)} packet`,
1400
- '',
1401
- `**Domain:** ${escapeMarkdown(caseRecord.domain)}`,
1402
- `**Category:** ${escapeMarkdown(category)}`,
1403
- `**Affected party:** ${escapeMarkdown(affectedParty)}`,
1404
- `**Observed at (UTC):** ${observedAt}`,
1405
- `**Generated at (UTC):** ${normalizedGeneratedAt}`,
1406
- `**Audience:** ${escapeMarkdown(profile.audience)}`,
1407
- `**Suggested subject:** ${escapeMarkdown(profile.subject)}`,
1408
- '',
1409
- '## Observed harm',
1410
- '',
1411
- escapeMarkdown(observedHarm),
1412
- '',
1413
- '## Exact abusive URLs',
1414
- '',
1415
- ...abusiveUrls.map((url) => `- ${escapeMarkdown(url)}`),
1416
- '',
1417
- '## Selected response route',
1418
- '',
1419
- ...(recipientRoute
1420
- ? [
1421
- `### ${contactLabel(recipientRoute.kind)}`,
1422
- '',
1423
- `- Case action: ${escapeMarkdown(recipientRoute.actionId)}`,
1424
- `- Contact: ${escapeMarkdown(recipientRoute.contact)}`,
1425
- `- Source: ${escapeMarkdown(recipientRoute.source)}`,
1426
- `- Route observed: ${recipientRoute.observedAt ?? 'Not provided'} (${recipientRoute.freshness})`,
1427
- `- Route review after: ${recipientRoute.reviewAfter ?? 'Not provided'}`,
1428
- `- Limitations: ${recipientRoute.limitations.length ? recipientRoute.limitations.map(escapeMarkdown).join('; ') : 'None recorded'}`,
1429
- '',
1430
- ]
1431
- : ['No profile-appropriate response route was bound to this packet.', '']),
1432
- '## Selected action lineage',
1433
- '',
1434
- ...(escalationHistory.length
1435
- ? escalationHistory.flatMap((action) => [
1436
- `- ${escapeMarkdown(action.type.replaceAll('_', ' '))} to ${escapeMarkdown(action.recipient)} · ${escapeMarkdown(action.state.replaceAll('_', ' '))} · updated ${action.updatedAt}`,
1437
- ` - Route observed: ${action.routeObservedAt ?? 'Not provided'}`,
1438
- ...(action.reference ? [` - Reference: ${escapeMarkdown(action.reference)}`] : []),
1439
- ...(action.providerOutcome ? [` - Typed provider outcome: ${escapeMarkdown(action.providerOutcome.replaceAll('_', ' '))}`] : []),
1440
- ...(action.outcomeDetail ? [` - Outcome detail: ${escapeMarkdown(action.outcomeDetail)}`] : []),
1441
- ...(action.originActionId ? [` - Originating action: ${escapeMarkdown(action.originActionId)}`] : []),
1442
- ...action.transitions.flatMap((event) => [
1443
- ` - ${event.occurredAt}: ${escapeMarkdown(event.previousState ?? 'none')} → ${escapeMarkdown(event.nextState)} · ${escapeMarkdown(event.sourceClass)} · ${escapeMarkdown(event.provenance)}${event.providerOutcome ? ` · ${escapeMarkdown(event.providerOutcome.replaceAll('_', ' '))}` : ''}${event.applied ? '' : ' · retained conflict'}`,
1444
- ...(event.reference ? [` - Reference: ${escapeMarkdown(event.reference)}`] : []),
1445
- ...(event.evidencePinId ? [` - Evidence pin: ${escapeMarkdown(event.evidencePinId)}`] : []),
1446
- ...(event.originActionId ? [` - Originating action: ${escapeMarkdown(event.originActionId)}`] : []),
1447
- ...event.limitations.map((limitation) => ` - Limitation: ${escapeMarkdown(limitation)}`),
1448
- ]),
1449
- ...(action.historyOmitted ? [` - ${action.historyOmitted} earlier transition event${action.historyOmitted === 1 ? '' : 's'} omitted by bound.`] : []),
1450
- ...action.historyLimitations.map((limitation) => ` - History limitation: ${escapeMarkdown(limitation)}`),
1451
- ])
1452
- : ['No Case action was selected.']),
1453
- ...(escalationHistoryOmitted ? [`- Unrelated Case actions excluded from packet: ${escalationHistoryOmitted}`] : []),
1454
- ...escalationHistoryLimitations.map((limitation) => `- Packet history limitation: ${escapeMarkdown(limitation)}`),
1455
- '',
1456
- '## Readiness and authorisation',
1457
- '',
1458
- `- Packet state: ${authorisation.status}`,
1459
- `- Reviewed-input SHA-256: ${authorisation.reviewedInputDigestSha256}`,
1460
- `- Supplied review digest matches: ${authorisation.digestMatches ? 'yes' : 'no'}`,
1461
- ...RESPONSE_AUTHORISATION_CONFIRMATION_IDS.map((id) => `- Confirmation ${id}: ${authorisation.confirmations[id] ? 'yes' : 'no'}`),
1462
- ...readiness.rows.flatMap((row) => [
1463
- `- ${escapeMarkdown(row.label)} [${row.state}]: ${escapeMarkdown(row.detail)}`,
1464
- ...row.limitations.map((limitation) => ` - Limitation: ${escapeMarkdown(limitation)}`),
1465
- ]),
1466
- ...authorisation.limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
1467
- '',
1468
- '## Selected evidence and integrity references',
1469
- '',
1470
- ...(selectedEvidence.length ? selectedEvidence.map((item) => `- ${escapeMarkdown(item.id)} · ${escapeMarkdown(item.label)} · ${escapeMarkdown(item.source)} · ${item.observedAt} · ${escapeMarkdown(item.completeness)}`) : ['- No evidence pin was explicitly selected.']),
1471
- ...artefactReferences.map((item) => `- ${escapeMarkdown(item.id)} · ${escapeMarkdown(item.label)} · SHA-256 ${item.digestSha256} · captured ${item.capturedAt}`),
1472
- '',
1473
- '## Provider outcome and independent effect',
1474
- '',
1475
- responseLifecycle.latestProviderOutcome
1476
- ? `- Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${escapeMarkdown(responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' '))})`
1477
- : `- Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,
1478
- responseLifecycle.latestObservedChangeAt
1479
- ? `- Independently observed change time: ${responseLifecycle.latestObservedChangeAt}`
1480
- : `- Independently observed change time: Withheld because the independent change state is ${responseLifecycle.observedChangeState}.`,
1481
- ...(responseLifecycle.latestObservedEffect ? [`- Latest independent review: ${escapeMarkdown(responseLifecycle.latestObservedEffect.state.replaceAll('_', ' '))} · ${responseLifecycle.latestObservedEffect.observedAt} · ${escapeMarkdown(responseLifecycle.latestObservedEffect.source)}`] : []),
1482
- ...responseLifecycle.limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
1483
- '',
1484
- '## Review and provenance',
1485
- '',
1486
- `- Preflight: ${preflight.status.replaceAll('_', ' ')} (${preflight.counts.pass} pass, ${preflight.counts.caution} caution, ${preflight.counts.block} block)`,
1487
- ...preflight.checks.map((check) => `- ${escapeMarkdown(check.label)} [${check.state}]: ${escapeMarkdown(check.detail)}`),
1488
- ...limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
1489
- `- Case evidence pins: ${caseRecord.evidencePins.length}`,
1490
- `- Case decision records: ${caseRecord.decisions.length}`,
1491
- `- Case structured assertions: ${caseRecord.assertions.length}`,
1492
- `- Observation-age band at export: ${age.band.replaceAll('_', ' ')}`,
1493
- `- Canonical packet SHA-256: ${digestSha256}`,
1494
- '- Digest scope: canonical sorted JSON packet excluding the integrity object',
1495
- '',
1496
- '## Audience profile',
1497
- '',
1498
- ...profile.checklist.map((item) => `- Checklist: ${escapeMarkdown(item)}`),
1499
- ...profile.includedEvidence.map((item) => `- Included: ${escapeMarkdown(item)}`),
1500
- ...profile.excludedEvidence.map((item) => `- Excluded: ${escapeMarkdown(item)}`),
1501
- ...profile.redactions.map((item) => `- Redaction to confirm: ${escapeMarkdown(item)}`),
1502
- ...profile.attachments.map((item) => `- Attachment expectation: ${escapeMarkdown(item)}`),
1503
- ...profile.followUpFields.map((item) => `- Follow-up field: ${escapeMarkdown(item)}`),
1504
- ];
1505
- const markdown = `${lines.join('\n').trim()}\n`;
1506
- const email = [
1507
- `Subject: ${profile.subject}`,
1508
- '',
1509
- 'Hello,',
1510
- '',
1511
- `I am reporting observed ${category} activity involving ${caseRecord.domain}.`,
1512
- `Affected party: ${affectedParty}`,
1513
- `Observed at (UTC): ${observedAt}`,
1514
- '',
1515
- 'Observed harm:',
1516
- observedHarm,
1517
- '',
1518
- 'Exact URLs:',
1519
- ...abusiveUrls.map((url) => `- ${url}`),
1520
- '',
1521
- 'Selected evidence:',
1522
- ...(selectedEvidence.length
1523
- ? selectedEvidence.map((item) => `- ${item.label} — ${item.source}, observed ${item.observedAt} (${item.completeness})`)
1524
- : ['- No Case evidence pin was selected.']),
1525
- '',
1526
- `Reviewed packet SHA-256: ${digestSha256}`,
1527
- 'Attach the reviewed packet (and any separately reviewed evidence files) through the recipient’s approved submission channel; this message does not embed or transmit attachments.',
1528
- '',
1529
- responseLifecycle.latestProviderOutcome
1530
- ? `Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' ')})`
1531
- : `Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,
1532
- responseLifecycle.latestObservedChangeAt
1533
- ? `Independently observed change time: ${responseLifecycle.latestObservedChangeAt}`
1534
- : `Independently observed change time: Withheld because the independent change state is ${responseLifecycle.observedChangeState}.`,
1535
- '',
1536
- 'Please review this report under the applicable abuse and acceptable-use policies.',
1537
- '',
1538
- authorisation.status === 'authorised'
1539
- ? 'This locally prepared packet is bound to explicit review confirmations. It was not submitted automatically and does not promise any provider outcome.'
1540
- : 'This is an unauthorised local draft with cautions. It was not submitted automatically and does not promise any provider outcome.',
1541
- ].join('\n');
1542
- return { json, markdown, email: `${email}\n` };
1007
+ return { json, ...renderCaseResponsePacket(json) };
1543
1008
  }
1544
1009
  export function caseResponsePacketFilename(domain, format, generatedAt) {
1545
1010
  const safeDomain = domain.toLowerCase().replace(/[^a-z0-9.-]+/gu, '-').replace(/^-+|-+$/gu, '').slice(0, 80) || 'case';