@slicedearth/whoisleuth-cli 2.4.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 (300) hide show
  1. package/NOTICE +2 -2
  2. package/cli/argument-values.mjs +55 -0
  3. package/cli/arguments.mjs +24 -470
  4. package/cli/artifact-validation/case-response.mjs +21 -6
  5. package/cli/artifact-validation/lookup-evidence.mjs +3 -3
  6. package/cli/artifact-validation/signed-review.mjs +59 -13
  7. package/cli/artifact-verify.mjs +2 -0
  8. package/cli/assurance-arguments.mjs +56 -0
  9. package/cli/assurance-command-definitions.mjs +179 -0
  10. package/cli/assurance-command-runner.mjs +1 -1
  11. package/cli/bounded-json.mjs +1 -1
  12. package/cli/bulk-checkpoint.mjs +1 -1
  13. package/cli/bulk-command-runner.mjs +2 -2
  14. package/cli/bulk.mjs +2 -3
  15. package/cli/case-command.mjs +20 -3
  16. package/cli/case-pack.mjs +12 -2
  17. package/cli/ci-report.mjs +6 -3
  18. package/cli/collection-arguments.mjs +123 -0
  19. package/cli/collection-command-definitions.mjs +330 -0
  20. package/cli/collection-preflight.mjs +8 -1
  21. package/cli/command-argument-grammar.mjs +8 -210
  22. package/cli/command-definition.mjs +329 -0
  23. package/cli/command-reference.mjs +55 -1388
  24. package/cli/command-seeds.mjs +18 -0
  25. package/cli/compare.mjs +1 -1
  26. package/cli/config-profile.mjs +1 -1
  27. package/cli/ct-event-intake.mjs +4 -4
  28. package/cli/discover.mjs +2 -2
  29. package/cli/discovery-observation-snapshot.mjs +2 -2
  30. package/cli/discovery-scan-command-runner.mjs +3 -3
  31. package/cli/discovery-scan.mjs +1 -1
  32. package/cli/discovery-snapshot.mjs +2 -2
  33. package/cli/discovery-workflow.mjs +4 -4
  34. package/cli/domain-control-monitor.mjs +6 -6
  35. package/cli/domain-control-observations.mjs +2 -1
  36. package/cli/errors.mjs +5 -6
  37. package/cli/evidence-arguments.mjs +46 -0
  38. package/cli/evidence-command-definitions.mjs +69 -0
  39. package/cli/evidence-command-runner.mjs +2 -3
  40. package/cli/evidence-signing.mjs +3 -2
  41. package/cli/external-observation-mapping.mjs +2 -2
  42. package/cli/formatters/terminal-command-formats.mjs +6 -0
  43. package/cli/formatters/terminal-lookup.mjs +6 -0
  44. package/cli/history-arguments.mjs +39 -0
  45. package/cli/history-command-definitions.mjs +88 -0
  46. package/cli/indicator-set-command.mjs +33 -0
  47. package/cli/inline-command-families.mjs +6 -47
  48. package/cli/inline-command-runner.mjs +4 -31
  49. package/cli/input.mjs +2 -2
  50. package/cli/intake-command.mjs +107 -0
  51. package/cli/interchange-report.mjs +4 -2
  52. package/cli/investigation-artifacts.mjs +1 -1
  53. package/cli/investigation-plan.mjs +32 -154
  54. package/cli/investigation-recipes.mjs +177 -0
  55. package/cli/investigation-run.mjs +78 -12
  56. package/cli/local-mmdb-review.mjs +197 -17
  57. package/cli/lookalike-calibration.mjs +1 -1
  58. package/cli/lookup-brief.mjs +2 -2
  59. package/cli/lookup-command-runner.mjs +18 -35
  60. package/cli/lookup-diff.mjs +10 -9
  61. package/cli/lookup-plan.mjs +10 -14
  62. package/cli/lookup-reconcile.mjs +6 -5
  63. package/cli/lookup-timeline.mjs +7 -6
  64. package/cli/mail-header-review.mjs +24 -247
  65. package/cli/mail-review.mjs +9 -7
  66. package/cli/network-arguments.mjs +51 -0
  67. package/cli/network-command-definitions.mjs +130 -0
  68. package/cli/network-command-runner.mjs +43 -31
  69. package/cli/offline-evidence-review.mjs +39 -6
  70. package/cli/posture.mjs +1 -1
  71. package/cli/registry-fixture-scaffold.mjs +1 -1
  72. package/cli/retained-artifact-diff.mjs +8 -7
  73. package/cli/review-arguments.mjs +131 -0
  74. package/cli/review-command-definitions.mjs +273 -0
  75. package/cli/review-command-runner.mjs +68 -158
  76. package/cli/risk-calibration.mjs +8 -8
  77. package/cli/runner.mjs +20 -32
  78. package/cli/saved-lookup.mjs +2 -2
  79. package/cli/selected-input-worker.mjs +63 -0
  80. package/cli/sharing-review.mjs +5 -64
  81. package/cli/source-reliability.mjs +2 -2
  82. package/cli/support-arguments.mjs +49 -0
  83. package/cli/support-command-definitions.mjs +209 -0
  84. package/cli/support-command-runner.mjs +2 -2
  85. package/cli/workflow-arguments.mjs +86 -0
  86. package/cli/workflow-command-definitions.mjs +78 -0
  87. package/cli/workflow-command-runner.mjs +8 -1
  88. package/docs/cli-reference.md +27 -0
  89. package/docs/cli.md +223 -2
  90. package/docs/contextual-reviews.md +155 -0
  91. package/lib/analyst-taxonomy.mjs +2 -40
  92. package/lib/application-version.mjs +1 -1
  93. package/lib/availability.mjs +13 -789
  94. package/lib/bounded-contract-normalizers.mjs +4 -0
  95. package/lib/bounded-json.mjs +2 -330
  96. package/lib/browser-library-profile.mjs +62 -84
  97. package/lib/candidate-provenance-bounds.mjs +2 -0
  98. package/lib/classify.mjs +1 -1
  99. package/lib/ct-query.mjs +2 -27
  100. package/lib/ct-response-bounds.mjs +2 -9
  101. package/lib/ct-search.mjs +83 -47
  102. package/lib/dmarc-discovery.mjs +62 -0
  103. package/lib/dns-convergence-review.mjs +21 -8
  104. package/lib/dns-delegation-health.mjs +12 -8
  105. package/lib/dns-inheritance-review.mjs +53 -63
  106. package/lib/dns-intelligence.mjs +9 -7
  107. package/lib/dns-query-coverage.mjs +40 -0
  108. package/lib/domain-assurance.mjs +1 -1
  109. package/lib/domain-change-packet.mjs +12 -13
  110. package/lib/domain-change-review.mjs +32 -14
  111. package/lib/domain-control-flight-recorder.mjs +103 -27
  112. package/lib/domain-control-manifest.mjs +25 -17
  113. package/lib/domain-posture-analysis.mjs +22 -5
  114. package/lib/domain-posture.mjs +16 -15
  115. package/lib/domain-registration.mjs +392 -0
  116. package/lib/evidence-export-privacy.mjs +22 -15
  117. package/lib/evidence-export.mjs +3 -3
  118. package/lib/evidence-report.mjs +4 -1
  119. package/lib/external-intelligence-risk.mjs +2 -124
  120. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  121. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  122. package/lib/generated/retire-browser-catalog.mjs +332 -19
  123. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  124. package/lib/html-signals.mjs +7 -2
  125. package/lib/http-evidence-bounds.mjs +2 -8
  126. package/lib/http-intelligence.mjs +13 -1
  127. package/lib/idn-confusable-policy.mjs +6 -4
  128. package/lib/idn-confusables.mjs +1 -1
  129. package/lib/interchange-fidelity-registry.mjs +10 -0
  130. package/lib/lookup-contract-primitives.mjs +1 -1
  131. package/lib/lookup-diagnostics.mjs +1 -23
  132. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  133. package/lib/lookup-progress.mjs +50 -1
  134. package/lib/lookup-response-contract.mjs +69 -40
  135. package/lib/lookup-response.mjs +5 -15
  136. package/lib/lookup-source-progress.mjs +8 -22
  137. package/lib/lookup.mjs +1 -1
  138. package/lib/opportunity-scoring.mjs +2 -107
  139. package/lib/outbound-identity.mjs +1 -1
  140. package/lib/page-fingerprints.mjs +18 -1
  141. package/lib/perceptual-hash-comparison.mjs +2 -34
  142. package/lib/perceptual-hash.mjs +36 -10
  143. package/lib/portable-generator.mjs +2 -29
  144. package/lib/project-metadata.mjs +2 -8
  145. package/lib/rdap-client.mjs +11 -7
  146. package/lib/registrable-domain.mjs +2 -56
  147. package/lib/registrar-standing-contract.mjs +1 -1
  148. package/lib/registrar-standing.mjs +9 -13
  149. package/lib/registry-capabilities.mjs +2 -2
  150. package/lib/registry-capability-catalogue.mjs +27 -19
  151. package/lib/risk-calibration-summary.mjs +2 -352
  152. package/lib/risk-scoring.mjs +2 -345
  153. package/lib/scoring-evidence-quality.mjs +2 -107
  154. package/lib/semantic-version.mjs +2 -54
  155. package/lib/service-binding-dns.mjs +27 -0
  156. package/lib/smtp-transport-review.mjs +48 -19
  157. package/lib/sslbl-certificates.generated.mjs +141 -139
  158. package/lib/static-html-analysis.mjs +1 -1
  159. package/lib/structured-data-identity.mjs +1 -1
  160. package/lib/threat-intelligence-manifests.mjs +1 -1
  161. package/lib/threat-intelligence-runtime.mjs +1 -1
  162. package/lib/threat-intelligence-targets.mjs +1 -1
  163. package/lib/threat-intelligence-types.mjs +2 -75
  164. package/lib/typosquat-generator.mjs +11 -2
  165. package/lib/website-enrichment.mjs +388 -0
  166. package/lib/whois-authority.mjs +43 -5
  167. package/lib/whois-normalization.mjs +3 -1
  168. package/lib/whois-parser.mjs +5 -3
  169. package/package.json +13 -14
  170. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  171. package/packages/analysis/bounded-json.mjs +330 -0
  172. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  173. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  174. package/packages/analysis/ct-query.mjs +27 -0
  175. package/packages/analysis/ct-response-bounds.mjs +9 -0
  176. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  177. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  178. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  179. package/packages/analysis/opportunity-scoring.mjs +107 -0
  180. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  181. package/packages/analysis/portable-generator.mjs +29 -0
  182. package/packages/analysis/project-metadata.mjs +8 -0
  183. package/packages/analysis/registrable-domain.mjs +56 -0
  184. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  185. package/packages/analysis/risk-scoring.mjs +345 -0
  186. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  187. package/packages/analysis/semantic-version.mjs +54 -0
  188. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  189. package/packages/cases/case-evidence-links.mjs +135 -0
  190. package/packages/cases/case-evidence-model.mjs +78 -12
  191. package/packages/cases/case-evidence-quality.mjs +5 -0
  192. package/packages/cases/case-export-input.mjs +7 -5
  193. package/packages/cases/case-incident-context.mjs +52 -0
  194. package/packages/cases/case-markdown.mjs +11 -0
  195. package/packages/cases/case-migration-model.mjs +40 -24
  196. package/packages/cases/case-recheck-model.mjs +90 -32
  197. package/packages/cases/case-record-contracts.mjs +1 -1
  198. package/packages/cases/case-record-core.mjs +1 -1
  199. package/packages/cases/case-record-operations.mjs +40 -112
  200. package/packages/cases/case-record-projection.mjs +8 -1
  201. package/packages/cases/case-record-version-input.mjs +19 -0
  202. package/packages/cases/case-report.mjs +63 -27
  203. package/packages/cases/case-requested-evidence.mjs +146 -0
  204. package/packages/cases/case-response-actions.mjs +103 -1
  205. package/packages/cases/case-response-model.mjs +9 -1
  206. package/packages/cases/case-response-outcomes.mjs +29 -8
  207. package/packages/cases/case-response-packet-render.mjs +13 -9
  208. package/packages/cases/case-response-packet.mjs +25 -11
  209. package/packages/cases/case-response-records.mjs +0 -1
  210. package/packages/cases/case-response-review-inputs.mjs +14 -3
  211. package/packages/cases/case-storage-model.mjs +1 -1
  212. package/packages/cases/case-workflow-metadata.mjs +225 -0
  213. package/packages/cases/case-workflow-migration.mjs +43 -0
  214. package/packages/cases/http-summary.mjs +6 -1
  215. package/packages/comparison/favicon-similarity.mjs +1 -1
  216. package/packages/comparison/page-similarity.mjs +1 -1
  217. package/packages/comparison/relationship-evidence.mjs +21 -9
  218. package/packages/comparison/relationship-provenance.mjs +7 -1
  219. package/packages/contracts/analyst-interchange.mjs +28 -10
  220. package/packages/contracts/case-portability.mjs +118 -25
  221. package/packages/contracts/cli-command-semantics.mjs +2 -0
  222. package/packages/contracts/cli-grammar.mjs +6 -0
  223. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  224. package/packages/contracts/context-review.mjs +24 -0
  225. package/packages/contracts/document-intake.mjs +11 -0
  226. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  227. package/packages/contracts/domain-control-manifest.mjs +1 -1
  228. package/packages/contracts/domain-control-monitor.mjs +13 -2
  229. package/packages/contracts/domain-control-review.mjs +36 -15
  230. package/packages/contracts/har-review.mjs +2 -0
  231. package/packages/contracts/identity-events.mjs +4 -0
  232. package/packages/contracts/investigation-run.mjs +2 -0
  233. package/packages/contracts/ip-address.mjs +10 -0
  234. package/packages/contracts/mail-authentication.mjs +5 -0
  235. package/packages/contracts/message-intake.mjs +31 -0
  236. package/packages/contracts/offline-comparison.mjs +4 -1
  237. package/packages/contracts/passive-mail.mjs +10 -0
  238. package/packages/contracts/web-capture.mjs +2 -2
  239. package/packages/contracts/workspace-portability.mjs +63 -17
  240. package/packages/evidence/collection-quality.mjs +67 -0
  241. package/packages/evidence/dns-query-outcome.mjs +72 -0
  242. package/packages/evidence/email-recipient.mjs +18 -0
  243. package/packages/evidence/lookup-target.mjs +5 -1
  244. package/packages/evidence/observation.mjs +2 -0
  245. package/packages/evidence/png-pixels.mjs +117 -0
  246. package/packages/evidence/public-address-policy.mjs +83 -0
  247. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  248. package/packages/interchange/external-findings-import.mjs +4 -12
  249. package/packages/interchange/managed-indicator-export.mjs +35 -0
  250. package/packages/interchange/managed-indicator-set.mjs +210 -0
  251. package/packages/interchange/misp-indicator-export.mjs +93 -0
  252. package/packages/interchange/sharing-policy.mjs +56 -0
  253. package/packages/interchange/stix-indicator-export.mjs +129 -0
  254. package/packages/interchange/web-capture-import.mjs +22 -15
  255. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  256. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  257. package/packages/investigation/capture-coverage.mjs +57 -0
  258. package/packages/investigation/connector-provenance-review.mjs +100 -0
  259. package/packages/investigation/context-review.mjs +35 -0
  260. package/packages/investigation/document-intake.mjs +81 -0
  261. package/packages/investigation/docx-intake.mjs +133 -0
  262. package/packages/investigation/domain-history-review.mjs +100 -0
  263. package/packages/investigation/domain-transition-review.mjs +47 -0
  264. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  265. package/packages/investigation/har-intake.mjs +68 -0
  266. package/packages/investigation/identity-event-intake.mjs +131 -0
  267. package/packages/investigation/identity-incident-review.mjs +39 -0
  268. package/packages/investigation/incident-sequence-review.mjs +58 -0
  269. package/packages/investigation/intake-report.mjs +19 -0
  270. package/packages/investigation/investigation-manifest.mjs +2 -2
  271. package/packages/investigation/link-intake.mjs +179 -0
  272. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  273. package/packages/investigation/mail-authentication-review.mjs +222 -0
  274. package/packages/investigation/mail-header-identity.mjs +209 -0
  275. package/packages/investigation/message-intake.mjs +195 -0
  276. package/packages/investigation/page-behaviour.mjs +113 -0
  277. package/packages/investigation/pdf-intake.mjs +155 -0
  278. package/packages/investigation/platform-continuity-review.mjs +60 -0
  279. package/packages/investigation/qr-intake.mjs +56 -0
  280. package/packages/investigation/requested-action-hints.mjs +11 -0
  281. package/packages/investigation/selected-input-review.mjs +17 -0
  282. package/packages/investigation/storefront-review.mjs +70 -0
  283. package/packages/workspace/brand-profile-model.mjs +2 -2
  284. package/packages/workspace/bulk-review-model.mjs +28 -26
  285. package/packages/workspace/bulk-session-model.mjs +23 -13
  286. package/packages/workspace/campaign-model.mjs +1 -1
  287. package/packages/workspace/detection-rule-model.mjs +1 -1
  288. package/packages/workspace/hostile-input.mjs +1 -1
  289. package/packages/workspace/investigation-guide.mjs +2 -2
  290. package/packages/workspace/investigation-template-model.mjs +26 -2
  291. package/packages/workspace/page-baseline.mjs +2 -2
  292. package/packages/workspace/relationship-observation-model.mjs +17 -13
  293. package/packages/workspace/shortlist-model.mjs +14 -5
  294. package/packages/workspace/watchlist-history.mjs +28 -12
  295. package/packages/workspace/watchlist-store.mjs +17 -5
  296. package/packages/workspace/website-snapshot-model.mjs +28 -3
  297. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  298. package/packages/workspace/workspace-archive.mjs +5 -4
  299. package/third-party-notices.txt +549 -16
  300. package/lib/hostname.mjs +0 -2
@@ -0,0 +1,135 @@
1
+ import { array, enumeration, exact, exactOptional, iso, text } from "../evidence/artifact-structure.mjs";
2
+ import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
3
+ import { MAX_CASE_EVIDENCE_PINS, MAX_RESPONSE_RATIONALE_LENGTH } from "../contracts/case-portability.mjs";
4
+ import { SAFE_ID_RE, freshId } from "./case-response-values.mjs";
5
+ export const CASE_EVIDENCE_LINK_KINDS = ['derived_from', 'shared_source'];
6
+ /** Enough for both directed relationships between every admitted pin pair. */
7
+ export const MAX_CASE_EVIDENCE_LINKS = MAX_CASE_EVIDENCE_PINS * (MAX_CASE_EVIDENCE_PINS - 1) * CASE_EVIDENCE_LINK_KINDS.length;
8
+ function identifier(value) {
9
+ const id = text(value, 'Evidence link identity', 64);
10
+ if (!SAFE_ID_RE.test(id))
11
+ throw new TypeError('Evidence link identity is invalid.');
12
+ return id;
13
+ }
14
+ function explanation(value) {
15
+ const result = text(value, 'Evidence relationship basis', MAX_RESPONSE_RATIONALE_LENGTH).trim();
16
+ if (!result)
17
+ throw new TypeError('Explain the evidence relationship.');
18
+ return result;
19
+ }
20
+ export function readCaseEvidenceLinks(value) {
21
+ if (value === undefined)
22
+ return undefined;
23
+ assertWorkspaceInputGraph(value, 'Evidence relationships');
24
+ const links = array(value, 'Evidence relationships', MAX_CASE_EVIDENCE_LINKS).map(raw => {
25
+ const row = exactOptional(raw, ['id', 'fromPinId', 'toPinId', 'kind', 'basis', 'createdAt'], ['withdrawal'], 'Evidence relationship');
26
+ const fromPinId = identifier(row.fromPinId), toPinId = identifier(row.toPinId);
27
+ if (fromPinId === toPinId)
28
+ throw new TypeError('An evidence pin cannot derive from or share a source with itself.');
29
+ iso(row.createdAt, 'Relationship recording time');
30
+ let withdrawal;
31
+ if (row.withdrawal !== undefined) {
32
+ const item = exact(row.withdrawal, ['at', 'reason'], 'Relationship withdrawal');
33
+ iso(item.at, 'Withdrawal time');
34
+ if (Date.parse(item.at) < Date.parse(row.createdAt))
35
+ throw new TypeError('Withdrawal precedes the relationship.');
36
+ withdrawal = { at: item.at, reason: explanation(item.reason) };
37
+ }
38
+ return { id: identifier(row.id), fromPinId, toPinId,
39
+ kind: enumeration(row.kind, CASE_EVIDENCE_LINK_KINDS, 'Evidence relationship kind'),
40
+ basis: explanation(row.basis), createdAt: row.createdAt, ...(withdrawal ? { withdrawal } : {}) };
41
+ });
42
+ if (new Set(links.map(link => link.id)).size !== links.length)
43
+ throw new TypeError('Evidence relationship identities must be unique.');
44
+ return links;
45
+ }
46
+ function derivationGraph(links) {
47
+ const graph = new Map();
48
+ for (const link of links)
49
+ if (!link.withdrawal && link.kind === 'derived_from') {
50
+ const neighbours = graph.get(link.fromPinId) ?? [];
51
+ neighbours.push(link.toPinId);
52
+ graph.set(link.fromPinId, neighbours);
53
+ }
54
+ return graph;
55
+ }
56
+ function reaches(graph, from, to) {
57
+ const pending = [from], visited = new Set();
58
+ while (pending.length) {
59
+ const id = pending.pop();
60
+ if (id === to)
61
+ return true;
62
+ if (visited.has(id))
63
+ continue;
64
+ visited.add(id);
65
+ pending.push(...(graph.get(id) ?? []));
66
+ }
67
+ return false;
68
+ }
69
+ export function appendCaseEvidenceLink(current, input, pins, now) {
70
+ const row = exact(input, ['fromPinId', 'toPinId', 'kind', 'basis'], 'New evidence relationship');
71
+ const link = readCaseEvidenceLinks([{ ...row, id: freshId('evidence-link'), createdAt: now }])[0];
72
+ const ids = new Set(pins.map(pin => pin.id));
73
+ if (!ids.has(link.fromPinId) || !ids.has(link.toPinId))
74
+ throw new TypeError('Select two retained evidence pins.');
75
+ if (current.some(item => !item.withdrawal && item.kind === link.kind
76
+ && ((item.fromPinId === link.fromPinId && item.toPinId === link.toPinId)
77
+ || (link.kind === 'shared_source' && item.fromPinId === link.toPinId && item.toPinId === link.fromPinId)))) {
78
+ throw new TypeError('This evidence relationship is already recorded. Withdraw it before recording a replacement.');
79
+ }
80
+ if (link.kind === 'derived_from' && reaches(derivationGraph(current), link.toPinId, link.fromPinId))
81
+ throw new TypeError('This derivation would create a cycle. Review the existing links first.');
82
+ return readCaseEvidenceLinks([...current, link]);
83
+ }
84
+ export function withdrawCaseEvidenceLink(current, input, now) {
85
+ const row = exact(input, ['id', 'reason'], 'Evidence relationship withdrawal');
86
+ const id = identifier(row.id), existing = current.find(link => link.id === id);
87
+ if (!existing || existing.withdrawal)
88
+ throw new TypeError('This relationship is missing or already withdrawn. Refresh before reviewing it.');
89
+ return readCaseEvidenceLinks(current.map(link => link.id === id ? { ...link, withdrawal: { at: now, reason: explanation(row.reason) } } : link));
90
+ }
91
+ /** Keep unresolved references; an imported pin can have been omitted by its source. */
92
+ export function mergeCaseEvidenceLinks(local, incoming) {
93
+ if (local === undefined && incoming === undefined)
94
+ return undefined;
95
+ const merged = new Map((readCaseEvidenceLinks(local) ?? []).map(link => [link.id, link]));
96
+ for (const link of readCaseEvidenceLinks(incoming) ?? []) {
97
+ const existing = merged.get(link.id);
98
+ if (!existing) {
99
+ merged.set(link.id, link);
100
+ continue;
101
+ }
102
+ const { withdrawal: left, ...a } = existing, { withdrawal: right, ...b } = link;
103
+ if (JSON.stringify(a) !== JSON.stringify(b) || (left && right && JSON.stringify(left) !== JSON.stringify(right))) {
104
+ throw new TypeError('An imported evidence relationship conflicts with retained provenance. No relationship was overwritten.');
105
+ }
106
+ merged.set(link.id, { ...a, ...((left ?? right) ? { withdrawal: left ?? right } : {}) });
107
+ }
108
+ return readCaseEvidenceLinks([...merged.values()].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
109
+ }
110
+ export function caseEvidenceLinkIssues(links, pins) {
111
+ const ids = new Set(pins.map(pin => pin.id));
112
+ const graph = derivationGraph(links);
113
+ return links.map(link => ({ link,
114
+ missingPinIds: [link.fromPinId, link.toPinId].filter(id => !ids.has(id)),
115
+ cyclic: !link.withdrawal && link.kind === 'derived_from' && reaches(graph, link.toPinId, link.fromPinId),
116
+ }));
117
+ }
118
+ /** Matching context is disclosed, never counted as independent corroboration. */
119
+ export function caseEvidenceSharedContext(pins) {
120
+ const groups = new Map();
121
+ function add(kind, identity, label, id) {
122
+ if (!identity?.trim())
123
+ return;
124
+ const key = JSON.stringify([kind, identity]);
125
+ const group = groups.get(key) ?? { kind, label, pinIds: [] };
126
+ group.pinIds.push(id);
127
+ groups.set(key, group);
128
+ }
129
+ for (const pin of pins) {
130
+ add('checkpoint', pin.checkpointId, 'Same collection checkpoint', pin.id);
131
+ add('import', pin.importContentSha256, 'Same imported content', pin.id);
132
+ add('source', pin.source, `Same declared source: ${pin.source}`, pin.id);
133
+ }
134
+ return [...groups.values()].filter(group => group.pinIds.length > 1);
135
+ }
@@ -1,12 +1,13 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { normalizeHttpSummary } from "./http-summary.mjs";
4
- import { normalizeOpportunityModelVersion } from "../../lib/opportunity-scoring.mjs";
5
- import { normalizeRiskModelVersion } from "../../lib/risk-scoring.mjs";
3
+ import { httpSummaryFieldIsObserved, normalizeHttpSummary } from "./http-summary.mjs";
4
+ import { normalizeOpportunityModelVersion } from "../analysis/opportunity-scoring.mjs";
5
+ import { normalizeRiskModelVersion } from "../analysis/risk-scoring.mjs";
6
6
  import { latestObservationCohort } from "../evidence/latest-observations.mjs";
7
7
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
8
- import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
8
+ import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
9
9
  import { validWebObservationMode } from "../evidence/lookup-target.mjs";
10
+ import { normalizeWebCollectionQuality, webCollectionAllowsComparison } from "../evidence/collection-quality.mjs";
10
11
  import { MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, } from "./case-record-contracts.mjs";
11
12
  import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, caseTimestampOrNull, hashString, isoOrNull, normalizeEvidenceHostnameForCase, objectRecord, safeId, } from "./case-record-core.mjs";
12
13
  function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
@@ -122,6 +123,7 @@ const MATERIAL_FIELD_ORDER = [
122
123
  'observationHostname',
123
124
  'webObservationMode',
124
125
  'scanDepth',
126
+ 'webCollectionQuality',
125
127
  'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
126
128
  'riskFactors', 'opportunityFactors',
127
129
  'registrar', 'createdDate', 'expiryDate', 'nameservers',
@@ -174,7 +176,7 @@ function isEmptyMaterial(value) {
174
176
  }
175
177
  // Fields that describe the capture rather than assert evidence, so they never
176
178
  // on their own keep an otherwise-empty snapshot alive.
177
- const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'confidence']);
179
+ const NON_EVIDENCE_MATERIAL = new Set(['inputHostname', 'observationHostname', 'webObservationMode', 'scanDepth', 'webCollectionQuality', 'confidence']);
178
180
  // A snapshot with no material evidence (only timestamps/source/depth, or only a
179
181
  // bare confidence/unknown-availability) is dropped rather than added to a
180
182
  // timeline.
@@ -194,7 +196,7 @@ function canonicalMaterialString(snapshot, legacyOrder = false) {
194
196
  const value = materialValue(field, snapshot, legacyOrder);
195
197
  // Optional collection context does not alter historical fingerprints when
196
198
  // absent; recorded context separates otherwise-identical captures.
197
- if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode') && value === null)
199
+ if ((field === 'inputHostname' || field === 'observationHostname' || field === 'webObservationMode' || field === 'webCollectionQuality') && value === null)
198
200
  continue;
199
201
  canonical[field] = value;
200
202
  }
@@ -231,6 +233,10 @@ function buildSnapshot(raw, options) {
231
233
  const legacyOrder = historicalSchema || (record.factorOrder === undefined && typeof record.fingerprint === 'string');
232
234
  const scanDepth = normalizeScanDepth(record.scanDepth);
233
235
  const httpSummary = normalizeHttpSummary(record);
236
+ const webCollectionQuality = normalizeWebCollectionQuality(record.webCollectionQuality, scanDepth);
237
+ if (webCollectionQuality && options.sourceVersion != null && options.sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
238
+ throw new TypeError('Web collection quality requires Case schema 17 or later; historical evidence was not reinterpreted.');
239
+ }
234
240
  const acceptsProfileContext = options.sourceVersion === undefined || Number(options.sourceVersion) >= 12;
235
241
  const acceptsInputHostname = options.sourceVersion === undefined || Number(options.sourceVersion) >= 13;
236
242
  const acceptsObservationHostname = options.sourceVersion === undefined || Number(options.sourceVersion) > PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
@@ -248,6 +254,7 @@ function buildSnapshot(raw, options) {
248
254
  ...(observationHostname ? { observationHostname } : {}),
249
255
  ...(acceptsObservationHostname && scanDepth !== 'fast' && record.webObservationMode === 'selected_url' ? { webObservationMode: record.webObservationMode } : {}),
250
256
  scanDepth,
257
+ ...(webCollectionQuality ? { webCollectionQuality } : {}),
251
258
  availability: evidenceString(record.availability),
252
259
  confidence: evidenceString(record.confidence),
253
260
  riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
@@ -300,6 +307,12 @@ function buildSnapshot(raw, options) {
300
307
  fields.riskModelVersion = null;
301
308
  if (fields.opportunityScore === null && fields.opportunityFactors.length === 0)
302
309
  fields.opportunityModelVersion = null;
310
+ if (webCollectionQuality) {
311
+ for (const field of ['hasPasswordField', 'hasExternalFormAction', 'pageBaselineMatch', 'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets']) {
312
+ if (fields[field] === false && !webCollectionAllowsComparison(field, webCollectionQuality, scanDepth))
313
+ fields[field] = null;
314
+ }
315
+ }
303
316
  // A fast capture never evaluates the deep signals, so any value supplied for
304
317
  // them (e.g. a profile's default `false`) is discarded as unevaluated.
305
318
  if (scanDepth === 'fast') {
@@ -404,6 +417,11 @@ function compareSnapshotChrono(a, b) {
404
417
  * @returns {CaseEvidenceSnapshot[]}
405
418
  */
406
419
  export function normalizeEvidenceHistory(rawList, options = {}) {
420
+ const ordered = [...evidenceByMaterial(rawList, options).values()].sort(compareSnapshotChrono);
421
+ const kept = ordered.slice(Math.max(0, ordered.length - MAX_EVIDENCE_SNAPSHOTS_PER_CASE));
422
+ return assignUniqueSnapshotIds(kept);
423
+ }
424
+ function evidenceByMaterial(rawList, options) {
407
425
  const list = Array.isArray(rawList) ? rawList : [];
408
426
  const byMaterial = new Map();
409
427
  for (const raw of list) {
@@ -415,9 +433,24 @@ export function normalizeEvidenceHistory(rawList, options = {}) {
415
433
  // collision can never merge two genuinely different snapshots.
416
434
  byMaterial.set(built.material, existing ? mergeDuplicateSnapshots(existing, built.snapshot) : built.snapshot);
417
435
  }
418
- const ordered = [...byMaterial.values()].sort(compareSnapshotChrono);
419
- const kept = ordered.slice(Math.max(0, ordered.length - MAX_EVIDENCE_SNAPSHOTS_PER_CASE));
420
- return assignUniqueSnapshotIds(kept);
436
+ return byMaterial;
437
+ }
438
+ /** Imports add observations without evicting existing local material. Ordinary
439
+ * collection still uses the newest-history policy in normalizeEvidenceHistory. */
440
+ export function mergeImportedEvidenceHistory(local, imported, caseDomain) {
441
+ const options = { source: 'import', fallback: null, caseDomain };
442
+ const retained = evidenceByMaterial(normalizeEvidenceHistory(local, options), options);
443
+ let omitted = 0;
444
+ for (const [material, incoming] of evidenceByMaterial(imported, options)) {
445
+ const existing = retained.get(material);
446
+ if (existing)
447
+ retained.set(material, mergeDuplicateSnapshots(existing, incoming));
448
+ else if (retained.size < MAX_EVIDENCE_SNAPSHOTS_PER_CASE)
449
+ retained.set(material, incoming);
450
+ else
451
+ omitted += 1;
452
+ }
453
+ return { records: assignUniqueSnapshotIds([...retained.values()].sort(compareSnapshotChrono)), omitted };
421
454
  }
422
455
  function assignUniqueSnapshotIds(snapshots) {
423
456
  const used = new Set();
@@ -455,6 +488,13 @@ export function latestCaseEvidence(record) {
455
488
  }
456
489
  /** Chronological presentation and comparison admission shared by the browser and Case report. */
457
490
  export function caseEvidenceTimeline(history) {
491
+ return buildEvidenceTimeline(history, true);
492
+ }
493
+ /** Reproduce published report fields only; not current analyst comparisons. */
494
+ export function publishedCaseEvidenceTimelineForVerification(history) {
495
+ return buildEvidenceTimeline(history, false);
496
+ }
497
+ function buildEvidenceTimeline(history, enforceCollectionQuality) {
458
498
  const dated = (history ?? []).map((snapshot) => ({
459
499
  snapshot,
460
500
  at: normalizeExplicitIsoTimestamp(snapshot.capturedAt),
@@ -475,10 +515,10 @@ export function caseEvidenceTimeline(history) {
475
515
  ? 'Equal-time snapshots have no unique before-and-after order; no temporal change is inferred.'
476
516
  : null;
477
517
  const comparable = previous && !orderingLimitation;
478
- const changes = comparable ? compareCaseEvidence(previous.snapshot, snapshot) : [];
518
+ const changes = comparable ? compareEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
479
519
  const incomparableReasons = orderingLimitation
480
520
  ? ['other']
481
- : comparable ? caseEvidenceIncomparableReasons(previous.snapshot, snapshot) : [];
521
+ : comparable ? incomparableReasonsForEvidence(previous.snapshot, snapshot, enforceCollectionQuality) : [];
482
522
  if (comparable && !changes.length && !incomparableReasons.length
483
523
  && canonicalMaterialString(previous.snapshot) !== canonicalMaterialString(snapshot))
484
524
  incomparableReasons.push('other');
@@ -562,6 +602,13 @@ function opportunityModelComparable(previous, current) {
562
602
  function valuesMateriallyEqual(field, previous, current) {
563
603
  return JSON.stringify(materialValue(field, previous)) === JSON.stringify(materialValue(field, current));
564
604
  }
605
+ function collectionFieldComparable(field, snapshot) {
606
+ if (field === 'registrar' || field === 'createdDate' || field === 'expiryDate')
607
+ return isPresent(snapshot[field]);
608
+ return field.startsWith('http')
609
+ ? httpSummaryFieldIsObserved(field, snapshot)
610
+ : webCollectionAllowsComparison(field, snapshot.webCollectionQuality, snapshot.scanDepth);
611
+ }
565
612
  /**
566
613
  * Explains material fields that comparison gates deliberately suppress. The
567
614
  * reasons are stable machine values for UI/report wording; they are not risk
@@ -569,14 +616,27 @@ function valuesMateriallyEqual(field, previous, current) {
569
616
  * in the same observation produced an ordinary material change.
570
617
  * @param {CaseEvidenceSnapshot | null | undefined} previous
571
618
  * @param {CaseEvidenceSnapshot | null | undefined} current
572
- * @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model'>}
619
+ * @returns {Array<'observation-context' | 'opportunity-model' | 'scan-depth' | 'risk-model' | 'collection-quality'>}
573
620
  */
574
621
  export function caseEvidenceIncomparableReasons(previous, current) {
622
+ return incomparableReasonsForEvidence(previous, current, true);
623
+ }
624
+ function incomparableReasonsForEvidence(previous, current, enforceCollectionQuality) {
575
625
  if (!previous || !current || previous.fingerprint === current.fingerprint)
576
626
  return [];
577
627
  const reasons = [];
578
628
  if (!sameObservationContext(previous, current))
579
629
  reasons.push('observation-context');
630
+ const missingRegistrationValue = ['registrar', 'createdDate', 'expiryDate'].some(field => !valuesMateriallyEqual(field, previous, current) && (!isPresent(previous[field]) || !isPresent(current[field])));
631
+ const incompleteWebFields = previous.scanDepth === 'deep' && current.scanDepth === 'deep'
632
+ && (JSON.stringify(previous.webCollectionQuality) !== JSON.stringify(current.webCollectionQuality)
633
+ || COMPARE_FIELDS.some(spec => (spec.modelGate !== 'risk' || riskModelComparable(previous, current))
634
+ && (spec.modelGate !== 'opportunity' || opportunityModelComparable(previous, current))
635
+ && !valuesMateriallyEqual(spec.field, previous, current)
636
+ && (!collectionFieldComparable(spec.field, previous)
637
+ || !collectionFieldComparable(spec.field, current))));
638
+ if (enforceCollectionQuality && (missingRegistrationValue || incompleteWebFields))
639
+ reasons.push('collection-quality');
580
640
  const hasRiskEvidence = previous.riskScore !== null || current.riskScore !== null
581
641
  || previous.riskFactors.length > 0 || current.riskFactors.length > 0;
582
642
  if (hasRiskEvidence && !riskModelComparable(previous, current))
@@ -763,6 +823,9 @@ function compareField(spec, before, after) {
763
823
  * @returns {Array<{ field: string, label: string, before: unknown, after: unknown, tone: string }>}
764
824
  */
765
825
  export function compareCaseEvidence(previous, current) {
826
+ return compareEvidence(previous, current, true);
827
+ }
828
+ function compareEvidence(previous, current, enforceCollectionQuality) {
766
829
  if (!previous || !current)
767
830
  return [];
768
831
  const bothDeep = previous.scanDepth === 'deep' && current.scanDepth === 'deep';
@@ -785,6 +848,9 @@ export function compareCaseEvidence(previous, current) {
785
848
  continue;
786
849
  if (spec.modelGate === 'opportunity' && !comparableOpportunityModel)
787
850
  continue;
851
+ if (enforceCollectionQuality && (!collectionFieldComparable(spec.field, previous)
852
+ || !collectionFieldComparable(spec.field, current)))
853
+ continue;
788
854
  const result = compareField(spec, previous[spec.field], current[spec.field]);
789
855
  if (result) {
790
856
  changes.push({ field: spec.field, label: spec.label, before: result.before, after: result.after, tone: result.tone });
@@ -0,0 +1,5 @@
1
+ /** Collection completeness does not establish a usable date, comparison or conclusion. */
2
+ export function casePinHasCompleteObservation(pin) {
3
+ return pin.completeness === 'complete' && !pin.truncated
4
+ && ['complete', 'success', 'reviewed', 'not_found'].includes(pin.sourceState ?? '');
5
+ }
@@ -1,14 +1,16 @@
1
- import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../../lib/bounded-json.mjs";
1
+ import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../analysis/bounded-json.mjs";
2
2
  import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
3
3
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
- import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
4
+ import { CASE_SCHEMA_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
5
5
  import { normalizeCaseStore } from "./case-migration-model.mjs";
6
6
  import { serializeCaseStore } from "./case-storage-model.mjs";
7
7
  /** Historical wire shape remains independent of current in-memory defaults. */
8
8
  export function canonicalCaseExportProjection(cases, caseVersion) {
9
- if (caseVersion !== PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
10
- return [...cases];
11
- return cases.map((value) => {
9
+ return cases.map((record) => {
10
+ const { workflowMetadata: _workflow, ...historical } = record;
11
+ const value = caseVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION ? historical : record;
12
+ if (caseVersion !== PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
13
+ return value;
12
14
  if ([...value.evidencePins, ...value.sightings].some((item) => item.observedAt === null)) {
13
15
  throw new TypeError('The published Case 15 format requires a known observation time on each retained pin and sighting.');
14
16
  }
@@ -0,0 +1,52 @@
1
+ // Pure incident context parsing and retention projection; no storage or Case mutation.
2
+ import { canonicalRegistrableDomain } from "../analysis/registrable-domain.mjs";
3
+ import { MAX_CASE_OBJECTIVE_LENGTH } from "../contracts/case-portability.mjs";
4
+ import { parseCredentialFreeHttpUrl } from "../evidence/lookup-target.mjs";
5
+ export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
6
+ function boundedContextText(value, maximum) {
7
+ return typeof value === 'string'
8
+ ? value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
9
+ : '';
10
+ }
11
+ export function normalizeCaseObjective(value) {
12
+ return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
13
+ }
14
+ export function parseIncidentUrlContext(value) {
15
+ const parsed = parseCredentialFreeHttpUrl(value, MAX_CASE_INCIDENT_URL_LENGTH);
16
+ if (!parsed)
17
+ return null;
18
+ const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
19
+ const registrableDomain = canonicalRegistrableDomain(hostname);
20
+ if (!registrableDomain)
21
+ return null;
22
+ const exactUrl = parsed.toString();
23
+ if (exactUrl.length > MAX_CASE_INCIDENT_URL_LENGTH)
24
+ return null;
25
+ return Object.freeze({
26
+ exactUrl,
27
+ hostname,
28
+ registrableDomain,
29
+ originUrl: parsed.origin,
30
+ hasPath: parsed.pathname !== '/',
31
+ hasQuery: Boolean(parsed.search),
32
+ hasFragment: Boolean(parsed.hash),
33
+ });
34
+ }
35
+ export function caseInvestigationContext(record) {
36
+ return record?.workflowMetadata?.investigationContext ?? null;
37
+ }
38
+ export function prepareCaseInvestigationContext(input) {
39
+ const objective = normalizeCaseObjective(input.objective);
40
+ if (!objective)
41
+ throw new Error('Enter the investigation objective before retaining Incident context.');
42
+ const parsed = parseIncidentUrlContext(input.incidentUrl);
43
+ if (!parsed)
44
+ throw new Error(`Enter one absolute HTTP(S) Incident URL of at most ${MAX_CASE_INCIDENT_URL_LENGTH} characters without credentials.`);
45
+ const retention = input.retainExactUrl ? 'exact' : 'origin_only';
46
+ const retainedUrl = input.retainExactUrl ? parsed.exactUrl : parsed.originUrl;
47
+ return Object.freeze({
48
+ objective,
49
+ incidentUrl: retainedUrl,
50
+ urlRetention: retention,
51
+ });
52
+ }
@@ -0,0 +1,11 @@
1
+ /** Keep untrusted Case prose literal, including block syntax and GFM autolinks. */
2
+ export function escapeCaseMarkdownInline(text) {
3
+ return String(text)
4
+ .replace(/[\r\n\u2028\u2029]+/g, ' ')
5
+ .replace(/([\\`*_{}\[\]<>()#+!|~])/g, '\\$1')
6
+ .replace(/^(\s*)([-=]+)(?=\s|$)/u, '$1\\$2')
7
+ .replace(/^(\s*\d+)\.(?=\s)/u, '$1\\.')
8
+ // Break automatic links without altering the underlying evidence value.
9
+ .replace(/\b([a-z][a-z0-9+.-]{1,31}):(?=\/\/)/gi, '$1\\:')
10
+ .replace(/\bwww\.(?=\S)/gi, 'www\\.');
11
+ }
@@ -1,11 +1,22 @@
1
- import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeCaseObjective, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, isValidDisposition, isValidSource, isValidStatus, } from "./case-record-model.mjs";
1
+ import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "../contracts/case-portability.mjs";
2
+ import { DEFAULT_DISPOSITION, DEFAULT_STATUS, isValidDisposition, isValidStatus } from "./case-record-decisions.mjs";
3
+ import { DEFAULT_SOURCE } from "./case-record-contracts.mjs";
4
+ import { deterministicId, caseTimestampOrNull, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, isValidSource, } from "./case-record-core.mjs";
5
+ import { normalizeCase } from "./case-record-operations.mjs";
6
+ import { normalizeCaseObjective } from "./case-incident-context.mjs";
7
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
8
+ import { mergeImportedEvidenceHistory, normalizeEvidenceHistory } from "./case-evidence-model.mjs";
2
9
  import { CASE_REPORT_SCHEMA, PUBLIC_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
3
10
  import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
4
11
  import { readCaseAttachments, mergeCaseAttachments } from "./case-attachment-model.mjs";
5
- import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
12
+ import { readCaseEvidenceLinks, mergeCaseEvidenceLinks } from "./case-evidence-links.mjs";
13
+ import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
14
+ import { assertBoundedJsonStructure } from "../analysis/bounded-json.mjs";
6
15
  import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
7
16
  import { caseInvestigationBranchReferences, mergeCaseInvestigationBranches, normalizeCaseInvestigationBranches, } from "./case-investigation-branch-model.mjs";
8
17
  import { buildCaseClosureLinkContext, mergeCaseActions, mergeCaseClosureHistories, mergeCaseObservedEffectHistories, MAX_CASE_ASSERTIONS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_SIGHTINGS, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, } from "./case-response-model.mjs";
18
+ import { readCaseWorkflowFields } from "./case-workflow-migration.mjs";
19
+ import { emptyCaseWorkflowMetadata, mergeCaseWorkflowMetadata } from "./case-workflow-metadata.mjs";
9
20
  export const MAX_CASE_INPUT_RECORDS = 2_000;
10
21
  function compareCodeUnits(left, right) {
11
22
  return left < right ? -1 : left > right ? 1 : 0;
@@ -81,6 +92,8 @@ export function normalizeCaseStore(raw) {
81
92
  function assertModernCaseShape(raw, sourceVersion) {
82
93
  for (const item of boundedCaseList(raw).items) {
83
94
  const itemRecord = objectRecord(item);
95
+ if (sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION && Object.hasOwn(itemRecord, 'evidenceLinks'))
96
+ throw new TypeError('Evidence relationships require the current Case schema.');
84
97
  if (sourceVersion < INCIDENT_CASE_SCHEMA_VERSION && Object.hasOwn(itemRecord, 'attachments')) {
85
98
  throw new TypeError('Retained file references require the current Case schema; no data was changed.');
86
99
  }
@@ -168,6 +181,8 @@ function boundedCaseList(raw) {
168
181
  */
169
182
  function extractImportPatch(raw, importedVersion) {
170
183
  const record = objectRecord(raw);
184
+ if (record.evidenceLinks !== undefined && importedVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION)
185
+ throw new TypeError('Evidence relationships require the current Case schema.');
171
186
  const domain = normalizeDomain(record.domain);
172
187
  if (!domain)
173
188
  return null;
@@ -204,6 +219,9 @@ function extractImportPatch(raw, importedVersion) {
204
219
  [record.sightings, sightings],
205
220
  ].reduce((total, [candidates, retained]) => total
206
221
  + Math.max(0, (Array.isArray(candidates) ? candidates.length : 0) - retained.length), 0);
222
+ const workflow = readCaseWorkflowFields(record, domain, assertions, importedVersion);
223
+ const hasWorkflow = record.workflowMetadata !== undefined || workflow.workflowMetadata.types.length > 0
224
+ || workflow.workflowMetadata.incidentTargets.length > 0 || workflow.workflowMetadata.investigationContext !== null;
207
225
  return {
208
226
  domain,
209
227
  rawId: typeof record.id === 'string' ? record.id : null,
@@ -235,7 +253,9 @@ function extractImportPatch(raw, importedVersion) {
235
253
  ? normalizeCaseInvestigationBranches(record.branches, normalizedFallback, branchReferences, timestampOptions)
236
254
  : [],
237
255
  attachments: importedVersion >= INCIDENT_CASE_SCHEMA_VERSION ? readCaseAttachments(record.attachments) : undefined,
238
- tags: normalizeTags(record.tags),
256
+ evidenceLinks: readCaseEvidenceLinks(record.evidenceLinks),
257
+ tags: workflow.tags,
258
+ workflowMetadata: hasWorkflow ? workflow.workflowMetadata : undefined,
239
259
  // Imported notes fall back only to the imported record's own timestamps
240
260
  // (never "now"), so a timestamp-less note gets a stable, deterministic time
241
261
  // and id, and re-importing the same file cannot manufacture a duplicate or a
@@ -245,16 +265,6 @@ function extractImportPatch(raw, importedVersion) {
245
265
  updatedAt: caseTimestampOrNull(record.updatedAt, importedVersion),
246
266
  };
247
267
  }
248
- /** @param {CaseNote[]} a @param {CaseNote[]} b @returns {CaseNote[]} */
249
- function unionNotes(a, b) {
250
- const byId = new Map();
251
- for (const note of [...a, ...b]) {
252
- if (!byId.has(note.id))
253
- byId.set(note.id, note);
254
- }
255
- const notes = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt));
256
- return notes.slice(Math.max(0, notes.length - MAX_NOTES_PER_CASE));
257
- }
258
268
  function retainLocalAuthoredRecords(local, imported, maximum) {
259
269
  const records = [...local];
260
270
  const retainedIds = new Set(records.map((item) => item.id));
@@ -271,13 +281,6 @@ function retainLocalAuthoredRecords(local, imported, maximum) {
271
281
  }
272
282
  return { records, omitted };
273
283
  }
274
- // Additive, deduplicated union of two evidence histories. Identical material
275
- // collapses (earliest firstCapturedAt, latest capturedAt), distinct snapshots
276
- // are retained subject to the per-case bound, and an older import can never
277
- // move an existing observation backwards.
278
- function mergeEvidenceHistories(local, imported, caseDomain) {
279
- return normalizeEvidenceHistory([...local, ...imported], { source: 'import', fallback: null, caseDomain });
280
- }
281
284
  /** @param {ImportPatch} patch @param {string} now @returns {CaseRecord} */
282
285
  function caseFromPatch(patch, now) {
283
286
  return {
@@ -289,6 +292,7 @@ function caseFromPatch(patch, now) {
289
292
  reviewReasonCode: patch.reviewReasonCode ?? null,
290
293
  brandProfileIds: patch.brandProfileIds,
291
294
  tags: patch.tags,
295
+ workflowMetadata: patch.workflowMetadata ?? emptyCaseWorkflowMetadata(),
292
296
  notes: patch.notes,
293
297
  source: patch.source ?? DEFAULT_SOURCE,
294
298
  evidenceHistory: patch.evidenceHistory,
@@ -302,6 +306,7 @@ function caseFromPatch(patch, now) {
302
306
  closures: patch.closures,
303
307
  branches: patch.branches,
304
308
  ...(patch.attachments === undefined ? {} : { attachments: patch.attachments }),
309
+ ...(patch.evidenceLinks === undefined ? {} : { evidenceLinks: patch.evidenceLinks }),
305
310
  createdAt: patch.createdAt || patch.updatedAt || now,
306
311
  updatedAt: patch.updatedAt || patch.createdAt || now,
307
312
  };
@@ -318,6 +323,8 @@ function caseFromPatch(patch, now) {
318
323
  function applyImportPatch(local, patch) {
319
324
  const importNewer = patch.updatedAt !== null && Date.parse(patch.updatedAt) > Date.parse(local.updatedAt);
320
325
  const fallback = patch.updatedAt || local.updatedAt;
326
+ const noteSelection = retainLocalAuthoredRecords(local.notes, patch.notes, MAX_NOTES_PER_CASE);
327
+ const historySelection = mergeImportedEvidenceHistory(local.evidenceHistory, patch.evidenceHistory, local.domain);
321
328
  const pinSelection = retainLocalAuthoredRecords(local.evidencePins, patch.evidencePins, MAX_CASE_EVIDENCE_PINS);
322
329
  const evidencePins = normalizeCaseEvidencePins(pinSelection.records, fallback);
323
330
  const pinIds = new Set(evidencePins.map((item) => item.id));
@@ -336,7 +343,9 @@ function applyImportPatch(local, patch) {
336
343
  const trailSelection = retainLocalAuthoredRecords(local.manualTrail, patch.manualTrail, MAX_CASE_MANUAL_TRAIL_EVENTS);
337
344
  const manualTrail = normalizeCaseManualTrail(trailSelection.records, fallback);
338
345
  const attachments = mergeCaseAttachments(local.attachments, patch.attachments);
346
+ const evidenceLinks = mergeCaseEvidenceLinks(local.evidenceLinks, patch.evidenceLinks);
339
347
  const authoredHistoryOmitted = patch.authoredHistoryOmitted
348
+ + noteSelection.omitted
340
349
  + pinSelection.omitted
341
350
  + decisionSelection.omitted
342
351
  + assertionSelection.omitted
@@ -350,7 +359,7 @@ function applyImportPatch(local, patch) {
350
359
  reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
351
360
  brandProfileIds: brandProfileReferences.ids,
352
361
  source: patch.source !== undefined && importNewer ? patch.source : local.source,
353
- evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory, local.domain),
362
+ evidenceHistory: historySelection.records,
354
363
  evidencePins,
355
364
  decisions,
356
365
  actions,
@@ -361,11 +370,15 @@ function applyImportPatch(local, patch) {
361
370
  closures,
362
371
  branches: mergeCaseInvestigationBranches(local.branches ?? [], patch.branches, fallback, branchReferences),
363
372
  ...(attachments === undefined ? {} : { attachments }),
373
+ ...(evidenceLinks === undefined ? {} : { evidenceLinks }),
364
374
  tags: normalizeTags([...local.tags, ...patch.tags]),
365
- notes: unionNotes(local.notes, patch.notes),
375
+ workflowMetadata: patch.workflowMetadata === undefined
376
+ ? local.workflowMetadata ?? emptyCaseWorkflowMetadata()
377
+ : mergeCaseWorkflowMetadata(local.workflowMetadata ?? emptyCaseWorkflowMetadata(), patch.workflowMetadata, importNewer, local.domain),
378
+ notes: normalizeNotes(noteSelection.records, fallback),
366
379
  createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
367
380
  updatedAt: importNewer ? (patch.updatedAt ?? local.updatedAt) : local.updatedAt,
368
- }, brandProfileReferencesOmitted: brandProfileReferences.omitted, authoredHistoryOmitted };
381
+ }, brandProfileReferencesOmitted: brandProfileReferences.omitted, authoredHistoryOmitted, evidenceHistoryOmitted: historySelection.omitted };
369
382
  }
370
383
  function pickFreeId(preferred, domain, used) {
371
384
  const wanted = safeId(preferred);
@@ -478,7 +491,7 @@ function caseCollectionImportEnvelope(importedRaw) {
478
491
  * reinterpreted.
479
492
  * @param {CaseRecord[]} localCases
480
493
  * @param {unknown} importedRaw
481
- * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number, authoredHistoryOmitted: number }}
494
+ * @returns {{ cases: CaseRecord[], added: number, updated: number, skipped: number, brandProfileReferencesOmitted: number, authoredHistoryOmitted: number, evidenceHistoryOmitted: number }}
482
495
  */
483
496
  export function mergeCases(localCases, importedRaw) {
484
497
  assertBoundedJsonStructure(importedRaw, 'Case import', CASE_INPUT_JSON_LIMITS);
@@ -507,6 +520,7 @@ export function mergeCases(localCases, importedRaw) {
507
520
  let skipped = imported.omitted;
508
521
  let brandProfileReferencesOmitted = 0;
509
522
  let authoredHistoryOmitted = 0;
523
+ let evidenceHistoryOmitted = 0;
510
524
  const fallback = new Date(0).toISOString();
511
525
  const importedIds = new Set();
512
526
  for (const item of imported.items) {
@@ -539,6 +553,7 @@ export function mergeCases(localCases, importedRaw) {
539
553
  byId.set(existing.id, merged.record);
540
554
  brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
541
555
  authoredHistoryOmitted += merged.authoredHistoryOmitted;
556
+ evidenceHistoryOmitted += merged.evidenceHistoryOmitted;
542
557
  updated += 1;
543
558
  }
544
559
  else if (byId.size < MAX_CASES) {
@@ -561,5 +576,6 @@ export function mergeCases(localCases, importedRaw) {
561
576
  skipped,
562
577
  brandProfileReferencesOmitted,
563
578
  authoredHistoryOmitted,
579
+ evidenceHistoryOmitted,
564
580
  };
565
581
  }