@slicedearth/whoisleuth-cli 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +83 -61
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,6 +1,7 @@
1
- import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, isValidDisposition, isValidSource, isValidStatus, } from "./case-record-model.mjs";
2
- import { CASE_REPORT_SCHEMA, PUBLIC_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";
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";
2
+ 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
3
  import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
4
+ import { readCaseAttachments, mergeCaseAttachments } from "./case-attachment-model.mjs";
4
5
  import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
5
6
  import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
6
7
  import { caseInvestigationBranchReferences, mergeCaseInvestigationBranches, normalizeCaseInvestigationBranches, } from "./case-investigation-branch-model.mjs";
@@ -32,8 +33,8 @@ function assignUniqueIds(cases) {
32
33
  }
33
34
  /**
34
35
  * Recovers a clean, bounded store from the current versioned envelope or an
35
- * internal bare array, drops malformed records, keeps a single
36
- * case per domain (most recently updated wins), caps to MAX_CASES by recency,
36
+ * internal bare array, drops malformed records, preserves distinct current
37
+ * Case identities, caps to MAX_CASES by recency,
37
38
  * and guarantees globally unique safe ids. Retired, future, accessor-bearing,
38
39
  * sparse, or otherwise non-JSON envelopes fail before normalisation.
39
40
  * @param {unknown} raw
@@ -53,44 +54,51 @@ export function normalizeCaseStore(raw) {
53
54
  if (sourceVersion > CASE_SCHEMA_VERSION) {
54
55
  throw new TypeError(`Case schema ${sourceVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
55
56
  }
56
- if (sourceVersion === CASE_SCHEMA_VERSION) {
57
- assertCurrentCaseShape(raw);
57
+ if (sourceVersion >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION) {
58
+ assertModernCaseShape(raw, sourceVersion);
58
59
  }
59
60
  }
60
- const byDomain = new Map();
61
+ const byIdentity = new Map();
61
62
  for (const item of boundedCaseList(raw).items) {
62
63
  const normalized = normalizeCase(item, undefined, fallback, sourceVersion ?? CASE_SCHEMA_VERSION);
63
64
  if (!normalized)
64
65
  continue;
65
- const existing = byDomain.get(normalized.domain);
66
+ // Published domain-centred stores retain their original recovery rule.
67
+ // Current incidents share a domain without sharing analyst decisions.
68
+ const key = sourceVersion !== null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION
69
+ ? normalized.domain : `${normalized.domain}\u0000${normalized.id}`;
70
+ const existing = byIdentity.get(key);
66
71
  if (!existing || Date.parse(normalized.updatedAt) >= Date.parse(existing.updatedAt)) {
67
- byDomain.set(normalized.domain, normalized);
72
+ byIdentity.set(key, normalized);
68
73
  }
69
74
  }
70
- const cases = [...byDomain.values()]
75
+ const cases = [...byIdentity.values()]
71
76
  .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
72
77
  .slice(0, MAX_CASES);
73
78
  assignUniqueIds(cases);
74
79
  return { version: CASE_SCHEMA_VERSION, cases };
75
80
  }
76
- function assertCurrentCaseShape(raw) {
81
+ function assertModernCaseShape(raw, sourceVersion) {
77
82
  for (const item of boundedCaseList(raw).items) {
78
83
  const itemRecord = objectRecord(item);
84
+ if (sourceVersion < INCIDENT_CASE_SCHEMA_VERSION && Object.hasOwn(itemRecord, 'attachments')) {
85
+ throw new TypeError('Retained file references require the current Case schema; no data was changed.');
86
+ }
79
87
  const evidenceHistory = itemRecord.evidenceHistory;
80
88
  for (const snapshot of Array.isArray(evidenceHistory) ? evidenceHistory : []) {
81
89
  const record = objectRecord(snapshot);
82
90
  if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'inputHostname')) {
83
- throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} evidence snapshots must declare their exact submitted hostname, including null; unreleased local Case checkpoints are not interpreted as the v2 format and no data was changed.`);
91
+ throw new TypeError(`Case schema ${sourceVersion} evidence snapshots must declare their exact submitted hostname, including null; no data was changed.`);
84
92
  }
85
93
  }
86
94
  const actions = itemRecord.actions;
87
95
  for (const action of Array.isArray(actions) ? actions : []) {
88
96
  const record = objectRecord(action);
89
97
  if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeObservedAt')) {
90
- throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} response actions must declare their route observation time, including null; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
98
+ throw new TypeError(`Case schema ${sourceVersion} response actions must declare their route observation time, including null; no data was changed.`);
91
99
  }
92
100
  if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeReviewAfter')) {
93
- throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} response actions must declare their route review deadline, including null; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
101
+ throw new TypeError(`Case schema ${sourceVersion} response actions must declare their route review deadline, including null; no data was changed.`);
94
102
  }
95
103
  }
96
104
  const decisions = itemRecord.decisions;
@@ -98,7 +106,7 @@ function assertCurrentCaseShape(raw) {
98
106
  const record = objectRecord(decision);
99
107
  if (Object.keys(record).length > 0
100
108
  && (!Object.hasOwn(record, 'confidence') || !Object.hasOwn(record, 'confidenceBasis'))) {
101
- throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} analyst decisions must declare confidence and its basis; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
109
+ throw new TypeError(`Case schema ${sourceVersion} analyst decisions must declare confidence and its basis; no data was changed.`);
102
110
  }
103
111
  }
104
112
  }
@@ -168,7 +176,7 @@ function extractImportPatch(raw, importedVersion) {
168
176
  || null;
169
177
  const normalizedFallback = importFallback || '1970-01-01T00:00:00.000Z';
170
178
  const timestampOptions = {
171
- legacyTimestamps: importedVersion < CASE_SCHEMA_VERSION,
179
+ legacyTimestamps: importedVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION,
172
180
  sourceVersion: importedVersion,
173
181
  };
174
182
  const rawEvidence = Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
@@ -199,6 +207,7 @@ function extractImportPatch(raw, importedVersion) {
199
207
  return {
200
208
  domain,
201
209
  rawId: typeof record.id === 'string' ? record.id : null,
210
+ title: typeof record.title === 'string' && importedVersion >= INCIDENT_CASE_SCHEMA_VERSION ? normalizeCaseObjective(record.title) : undefined,
202
211
  status: isValidStatus(record.status) ? record.status : undefined,
203
212
  disposition: isValidDisposition(record.disposition) ? record.disposition : undefined,
204
213
  reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
@@ -225,6 +234,7 @@ function extractImportPatch(raw, importedVersion) {
225
234
  branches: importedVersion >= 11
226
235
  ? normalizeCaseInvestigationBranches(record.branches, normalizedFallback, branchReferences, timestampOptions)
227
236
  : [],
237
+ attachments: importedVersion >= INCIDENT_CASE_SCHEMA_VERSION ? readCaseAttachments(record.attachments) : undefined,
228
238
  tags: normalizeTags(record.tags),
229
239
  // Imported notes fall back only to the imported record's own timestamps
230
240
  // (never "now"), so a timestamp-less note gets a stable, deterministic time
@@ -273,6 +283,7 @@ function caseFromPatch(patch, now) {
273
283
  return {
274
284
  id: '', // assigned by mergeCases so it can guarantee uniqueness against locals
275
285
  domain: patch.domain,
286
+ title: patch.title ?? '',
276
287
  status: patch.status ?? DEFAULT_STATUS,
277
288
  disposition: patch.disposition ?? DEFAULT_DISPOSITION,
278
289
  reviewReasonCode: patch.reviewReasonCode ?? null,
@@ -290,6 +301,7 @@ function caseFromPatch(patch, now) {
290
301
  observedEffects: patch.observedEffects,
291
302
  closures: patch.closures,
292
303
  branches: patch.branches,
304
+ ...(patch.attachments === undefined ? {} : { attachments: patch.attachments }),
293
305
  createdAt: patch.createdAt || patch.updatedAt || now,
294
306
  updatedAt: patch.updatedAt || patch.createdAt || now,
295
307
  };
@@ -323,6 +335,7 @@ function applyImportPatch(local, patch) {
323
335
  const decisions = normalizeCaseDecisions(decisionSelection.records, fallback, pinIds);
324
336
  const trailSelection = retainLocalAuthoredRecords(local.manualTrail, patch.manualTrail, MAX_CASE_MANUAL_TRAIL_EVENTS);
325
337
  const manualTrail = normalizeCaseManualTrail(trailSelection.records, fallback);
338
+ const attachments = mergeCaseAttachments(local.attachments, patch.attachments);
326
339
  const authoredHistoryOmitted = patch.authoredHistoryOmitted
327
340
  + pinSelection.omitted
328
341
  + decisionSelection.omitted
@@ -331,6 +344,7 @@ function applyImportPatch(local, patch) {
331
344
  + sightingSelection.omitted;
332
345
  return { record: {
333
346
  ...local,
347
+ title: patch.title !== undefined && importNewer ? patch.title : local.title ?? '',
334
348
  status: patch.status !== undefined && importNewer ? patch.status : local.status,
335
349
  disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
336
350
  reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
@@ -346,6 +360,7 @@ function applyImportPatch(local, patch) {
346
360
  observedEffects,
347
361
  closures,
348
362
  branches: mergeCaseInvestigationBranches(local.branches ?? [], patch.branches, fallback, branchReferences),
363
+ ...(attachments === undefined ? {} : { attachments }),
349
364
  tags: normalizeTags([...local.tags, ...patch.tags]),
350
365
  notes: unionNotes(local.notes, patch.notes),
351
366
  createdAt: patch.createdAt && Date.parse(patch.createdAt) < Date.parse(local.createdAt) ? patch.createdAt : local.createdAt,
@@ -456,7 +471,7 @@ function caseCollectionImportEnvelope(importedRaw) {
456
471
  }
457
472
  /**
458
473
  * Merges an imported (already parsed) value into the local cases. Predictable
459
- * and idempotent: unknown records are skipped, existing domains merge without
474
+ * and idempotent: unknown records are skipped, matching Case identities merge without
460
475
  * losing newer local decisions, and new ones are added until the store bound is
461
476
  * reached. An imported envelope that declares a schema version newer than we
462
477
  * support is rejected up front (before any local data is touched) rather than
@@ -480,11 +495,11 @@ export function mergeCases(localCases, importedRaw) {
480
495
  || !Array.isArray(importedEnvelope.cases)) {
481
496
  throw new Error(`Expected a well-formed WHOISleuth Case export using schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
482
497
  }
483
- if (importedVersion === CASE_SCHEMA_VERSION)
484
- assertCurrentCaseShape(importedEnvelope);
498
+ if (importedVersion !== null && importedVersion >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION)
499
+ assertModernCaseShape(importedEnvelope, importedVersion);
485
500
  const local = normalizeCaseStore(localCases).cases;
486
501
  const supportedImportedVersion = importedVersion ?? 0;
487
- const byDomain = new Map(local.map((item) => [item.domain, item]));
502
+ const byId = new Map(local.map((item) => [item.id, item]));
488
503
  const usedIds = new Set(local.map((item) => item.id));
489
504
  let added = 0;
490
505
  let updated = 0;
@@ -493,25 +508,44 @@ export function mergeCases(localCases, importedRaw) {
493
508
  let brandProfileReferencesOmitted = 0;
494
509
  let authoredHistoryOmitted = 0;
495
510
  const fallback = new Date(0).toISOString();
511
+ const importedIds = new Set();
496
512
  for (const item of imported.items) {
497
513
  const patch = extractImportPatch(item, supportedImportedVersion);
498
514
  if (!patch) {
499
515
  skipped += 1;
500
516
  continue;
501
517
  }
502
- const existing = byDomain.get(patch.domain);
518
+ const importedId = safeId(patch.rawId);
519
+ if (supportedImportedVersion >= INCIDENT_CASE_SCHEMA_VERSION && importedId) {
520
+ if (importedIds.has(importedId))
521
+ throw new Error('Incident Case imports cannot repeat a declared Case ID. No data was changed.');
522
+ importedIds.add(importedId);
523
+ }
524
+ let existing = importedId ? byId.get(importedId) : undefined;
525
+ if (existing && existing.domain !== patch.domain) {
526
+ if (supportedImportedVersion >= INCIDENT_CASE_SCHEMA_VERSION)
527
+ throw new Error('An imported Case ID belongs to a different domain in this workspace. No data was changed.');
528
+ existing = undefined;
529
+ }
530
+ if (!existing && (supportedImportedVersion < INCIDENT_CASE_SCHEMA_VERSION || !importedId)) {
531
+ const matchingDomain = [...byId.values()].filter(record => record.domain === patch.domain);
532
+ if (matchingDomain.length > 1) {
533
+ throw new Error(`The imported record for ${patch.domain} has no matching Case identity and several incidents exist. Import it into a separate workspace to review it without merging incidents; no data was changed.`);
534
+ }
535
+ existing = matchingDomain[0];
536
+ }
503
537
  if (existing) {
504
538
  const merged = applyImportPatch(existing, patch);
505
- byDomain.set(patch.domain, merged.record);
539
+ byId.set(existing.id, merged.record);
506
540
  brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted + merged.brandProfileReferencesOmitted;
507
541
  authoredHistoryOmitted += merged.authoredHistoryOmitted;
508
542
  updated += 1;
509
543
  }
510
- else if (byDomain.size < MAX_CASES) {
544
+ else if (byId.size < MAX_CASES) {
511
545
  const record = caseFromPatch(patch, fallback);
512
546
  record.id = pickFreeId(patch.rawId, patch.domain, usedIds);
513
547
  usedIds.add(record.id);
514
- byDomain.set(patch.domain, record);
548
+ byId.set(record.id, record);
515
549
  brandProfileReferencesOmitted += patch.brandProfileReferencesOmitted;
516
550
  authoredHistoryOmitted += patch.authoredHistoryOmitted;
517
551
  added += 1;
@@ -521,7 +555,7 @@ export function mergeCases(localCases, importedRaw) {
521
555
  }
522
556
  }
523
557
  return {
524
- cases: normalizeCaseStore([...byDomain.values()]).cases,
558
+ cases: normalizeCaseStore([...byId.values()]).cases,
525
559
  added,
526
560
  updated,
527
561
  skipped,
@@ -3,3 +3,4 @@
3
3
  export * from "./case-record-model.mjs";
4
4
  export * from "./case-migration-model.mjs";
5
5
  export * from "./case-storage-model.mjs";
6
+ export * from "./case-selection.mjs";
@@ -0,0 +1,91 @@
1
+ import { isValidAsciiHostname } from "../../lib/hostname.mjs";
2
+ import { INCIDENT_CASE_SCHEMA_VERSION, MAX_RESPONSE_RATIONALE_LENGTH } from "../contracts/case-portability.mjs";
3
+ import { enumeration, exact, text } from "../evidence/artifact-structure.mjs";
4
+ export const CASE_RECHECK_CONDITIONS = Object.freeze({ unknown: 'Not established', comparable: 'Comparable for this question', different: 'Different conditions' });
5
+ function reference(value, label) {
6
+ const id = text(value, label, 64);
7
+ if (!/^[A-Za-z0-9_-]+$/u.test(id))
8
+ throw new TypeError(`${label} is invalid.`);
9
+ return id;
10
+ }
11
+ function contextFields(item) {
12
+ const targetHostname = text(item.targetHostname, 'Recheck target hostname', 253);
13
+ if (targetHostname !== targetHostname.toLowerCase() || !isValidAsciiHostname(targetHostname)) {
14
+ throw new TypeError('Use one normalised hostname for the recheck target, without a path or query.');
15
+ }
16
+ const conditions = text(item.conditions, 'Recheck comparison conditions', MAX_RESPONSE_RATIONALE_LENGTH);
17
+ if (!conditions.trim())
18
+ throw new TypeError('Describe the conditions needed to compare the observations.');
19
+ return Object.freeze({ targetHostname, conditions,
20
+ baselinePinId: item.baselinePinId === null ? null : reference(item.baselinePinId, 'Recheck baseline pin') });
21
+ }
22
+ /** Optional current-format context; historical records have no invented question. */
23
+ export function readCaseRecheckContext(value, sourceVersion) {
24
+ if (value === undefined)
25
+ return undefined;
26
+ if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
27
+ throw new TypeError('This Case format cannot contain structured recheck questions.');
28
+ return contextFields(exact(value, ['targetHostname', 'baselinePinId', 'conditions'], 'Recheck question context'));
29
+ }
30
+ export function readCaseRecheckAnswerContext(value, sourceVersion) {
31
+ if (value === undefined)
32
+ return undefined;
33
+ if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
34
+ throw new TypeError('This Case format cannot contain structured recheck answers.');
35
+ const item = exact(value, ['targetHostname', 'baselinePinId', 'conditions', 'questionId', 'question', 'conditionsMatch'], 'Recheck answer context');
36
+ const question = text(item.question, 'Recheck question', MAX_RESPONSE_RATIONALE_LENGTH);
37
+ if (!question.trim())
38
+ throw new TypeError('A recheck answer requires its original question.');
39
+ return Object.freeze({ ...contextFields(item), questionId: reference(item.questionId, 'Recheck question ID'),
40
+ question,
41
+ conditionsMatch: enumeration(item.conditionsMatch, Object.keys(CASE_RECHECK_CONDITIONS), 'Recheck condition comparison') });
42
+ }
43
+ export function caseRecheckQuestions(assertions) {
44
+ return assertions.filter(item => item.kind === 'next_step' && item.recheck && item.state === 'open');
45
+ }
46
+ export function caseRecheckAnswerContext(question, conditionsMatch) {
47
+ if (question.kind !== 'next_step' || !question.recheck)
48
+ throw new TypeError('Select a saved recheck question.');
49
+ return readCaseRecheckAnswerContext({ ...question.recheck, questionId: question.id, question: question.statement, conditionsMatch });
50
+ }
51
+ /** A saved answer is a snapshot, not a live reference to an editable plan. */
52
+ export function assertCurrentRecheckQuestion(answer, assertions) {
53
+ const question = caseRecheckQuestions(assertions).find(item => item.id === answer.questionId);
54
+ if (!question || JSON.stringify(caseRecheckAnswerContext(question, answer.conditionsMatch)) !== JSON.stringify(answer)) {
55
+ throw new Error('The recheck question changed or was resolved. Refresh and review its current conditions before saving this answer.');
56
+ }
57
+ }
58
+ export function caseRecheckComparisonWarnings(context, pins, current) {
59
+ const warnings = [];
60
+ if (context.conditionsMatch !== 'comparable')
61
+ warnings.push(context.conditionsMatch === 'different'
62
+ ? 'The comparison conditions differ.' : 'Comparable conditions have not been confirmed.');
63
+ const baseline = pins.find(pin => pin.id === context.baselinePinId);
64
+ if (context.baselinePinId && !baseline)
65
+ warnings.push('The baseline evidence is no longer available.');
66
+ if (current) {
67
+ if (current.observationHostname !== context.targetHostname)
68
+ warnings.push('The current evidence concerns a different or unknown hostname.');
69
+ if (current.completeness !== 'complete' || current.truncated || current.sourceState !== 'complete' && current.sourceState !== 'success' && current.sourceState !== 'reviewed' && current.sourceState !== 'not_found')
70
+ warnings.push('The current source does not establish a complete observation.');
71
+ if (baseline) {
72
+ if (baseline.id === current.id || !baseline.observedAt || !current.observedAt || Date.parse(current.observedAt) <= Date.parse(baseline.observedAt))
73
+ warnings.push('A later source observation is needed; reviewing the baseline again is not a recheck.');
74
+ if (baseline.observationHostname && baseline.observationHostname !== context.targetHostname)
75
+ warnings.push('The baseline concerns a different hostname.');
76
+ if (baseline.field && current.field !== baseline.field || baseline.source !== current.source)
77
+ warnings.push('The observations use different fields or sources.');
78
+ }
79
+ }
80
+ return warnings;
81
+ }
82
+ export function assertRecheckNonReproduction(state, context, completeness, pins, current, observedAt) {
83
+ if (state !== 'not_reproduced')
84
+ return;
85
+ const warnings = caseRecheckComparisonWarnings(context, pins, current);
86
+ const baseline = pins.find(pin => pin.id === context.baselinePinId);
87
+ if (!current && baseline && (!baseline.observedAt || !observedAt || !Number.isFinite(Date.parse(observedAt)) || Date.parse(observedAt) <= Date.parse(baseline.observedAt)))
88
+ warnings.push('A later independent observation is needed.');
89
+ if (completeness !== 'complete' || warnings.length)
90
+ throw new Error('Not reproduced requires a complete observation under comparable conditions. Record unavailable or describe the limited observation instead.');
91
+ }
@@ -1,8 +1,9 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { CASE_SCHEMA_VERSION, CASE_REVIEW_REASONS, CASE_SOURCES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
3
+ import { CASE_REVIEW_REASONS, CASE_SOURCES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
4
4
  import { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, } from "./case-record-decisions.mjs";
5
5
  import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
6
+ import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
6
7
  import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
7
8
  // Forward-version policy (two distinct guarantees):
8
9
  // - A locally-stored envelope that declares a version greater than this is
@@ -163,7 +164,7 @@ export function caseTimestampOrNull(value, sourceVersion) {
163
164
  return explicit;
164
165
  return sourceVersion !== undefined
165
166
  && sourceVersion !== null
166
- && sourceVersion < CASE_SCHEMA_VERSION
167
+ && sourceVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION
167
168
  ? normalizeLegacyIsoTimestamp(value)
168
169
  : null;
169
170
  }
@@ -1,15 +1,20 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
3
  import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
4
+ import { parseCredentialFreeHttpUrl } from "../evidence/lookup-target.mjs";
5
+ import { selectExistingCase } from "./case-selection.mjs";
4
6
  import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseObservedEffectReview, appendCaseSighting, buildCaseClosureLinkContext, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.mjs";
5
- import { CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
7
+ import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
8
+ import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, MAX_CASE_OBJECTIVE_LENGTH } from "../contracts/case-portability.mjs";
9
+ import { readCaseAttachments } from "./case-attachment-model.mjs";
10
+ import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
6
11
  import { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, } from "./case-record-decisions.mjs";
7
12
  import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, caseTimestampOrNull, deterministicId, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.mjs";
8
13
  import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
9
14
  import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
10
15
  import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
11
16
  export { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, };
12
- export const MAX_CASE_OBJECTIVE_LENGTH = 320;
17
+ export { MAX_CASE_OBJECTIVE_LENGTH };
13
18
  export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
14
19
  export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
15
20
  const OBJECTIVE_PREFIX = 'Objective: ';
@@ -23,18 +28,8 @@ export function normalizeCaseObjective(value) {
23
28
  return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
24
29
  }
25
30
  export function parseIncidentUrlContext(value) {
26
- if (typeof value !== 'string' || !value || value.length > MAX_CASE_INCIDENT_URL_LENGTH)
27
- return null;
28
- if (/[\u0000-\u001f\u007f]/u.test(value) || value.trim() !== value)
29
- return null;
30
- let parsed;
31
- try {
32
- parsed = new URL(value);
33
- }
34
- catch {
35
- return null;
36
- }
37
- if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
31
+ const parsed = parseCredentialFreeHttpUrl(value, MAX_CASE_INCIDENT_URL_LENGTH);
32
+ if (!parsed)
38
33
  return null;
39
34
  const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
40
35
  const registrableDomain = canonicalRegistrableDomain(hostname);
@@ -142,7 +137,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
142
137
  const createdAt = existing ? existing.createdAt : caseTimestampOrNull(record.createdAt, sourceVersion) || now;
143
138
  const updatedAt = caseTimestampOrNull(record.updatedAt, sourceVersion) || createdAt;
144
139
  const timestampOptions = {
145
- legacyTimestamps: sourceVersion != null && sourceVersion < CASE_SCHEMA_VERSION,
140
+ legacyTimestamps: sourceVersion != null && sourceVersion < PUBLISHED_V2_3_CASE_SCHEMA_VERSION,
146
141
  ...(sourceVersion === undefined ? {} : { sourceVersion }),
147
142
  };
148
143
  const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt, timestampOptions);
@@ -155,9 +150,11 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
155
150
  const closures = normalizeCaseClosureHistory(sourceVersion != null && sourceVersion < 13 ? undefined : record.closures, updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), timestampOptions, buildCaseClosureLinkContext(observedEffects, actions));
156
151
  const normalizedStatus = normalizeStatus(record.status);
157
152
  const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
153
+ const attachments = readCaseAttachments(record.attachments);
158
154
  return {
159
155
  id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
160
156
  domain,
157
+ title: sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION ? '' : normalizeCaseObjective(record.title),
161
158
  status: caseStatusRequiresClosure(normalizedStatus)
162
159
  && closures.records.length === 0 && !closures.preV13HistoryUnavailable
163
160
  ? 'reviewing'
@@ -178,6 +175,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
178
175
  observedEffects,
179
176
  closures,
180
177
  branches: normalizeCaseInvestigationBranches(record.branches, updatedAt, branchReferences, timestampOptions),
178
+ ...(attachments === undefined ? {} : { attachments }),
181
179
  createdAt,
182
180
  updatedAt,
183
181
  };
@@ -213,6 +211,12 @@ export function createCase(input, nowIso) {
213
211
  const observedEffects = input.observedEffectReview !== undefined
214
212
  ? appendCaseObservedEffectReview(normalizeCaseObservedEffectHistory(undefined, now), input.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)))
215
213
  : normalizeCaseObservedEffectHistory(undefined, now);
214
+ for (const review of observedEffects.reviews) {
215
+ if (!review.recheck)
216
+ continue;
217
+ assertCurrentRecheckQuestion(review.recheck, assertions);
218
+ assertRecheckNonReproduction(review.state, review.recheck, review.completeness, evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), review.observedAt);
219
+ }
216
220
  const closures = input.closure !== undefined
217
221
  ? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
218
222
  : normalizeCaseClosureHistory(undefined, now);
@@ -222,6 +226,7 @@ export function createCase(input, nowIso) {
222
226
  return {
223
227
  id: makeId(),
224
228
  domain,
229
+ title: normalizeCaseObjective(input.title),
225
230
  status: input.closure !== undefined ? 'resolved' : normalizeStatus(input.status),
226
231
  disposition: normalizeDisposition(input.disposition),
227
232
  reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
@@ -254,25 +259,46 @@ export function createCase(input, nowIso) {
254
259
  };
255
260
  }
256
261
  /**
257
- * Opens the existing case for a domain, or creates one. Returns a new array
262
+ * Opens an explicitly selected or unambiguous Case, or creates one. Returns a new array
258
263
  * (callers persist it) plus the resolved record and whether it was created.
259
264
  * @param {CaseRecord[]} cases
260
265
  * @param {{ domain: unknown, status?: unknown, disposition?: unknown, source?: unknown, tags?: unknown, evidence?: unknown, note?: unknown }} input
261
266
  * @param {string} [nowIso]
262
267
  * @returns {{ cases: CaseRecord[], record: CaseRecord, created: boolean }}
263
268
  */
264
- export function openOrCreateCase(cases, input, nowIso) {
269
+ export function openOrCreateCase(cases, input, nowIso, selection = {}) {
265
270
  const domain = normalizeDomain(input.domain);
266
271
  if (!domain)
267
272
  throw new Error('A valid domain is required to open a case.');
268
- const existing = cases.find((item) => item.domain === domain);
273
+ const existing = selectExistingCase(cases, domain, selection);
269
274
  if (existing)
270
275
  return { cases, record: existing, created: false };
271
276
  if (cases.length >= MAX_CASES)
272
277
  throw new Error(`Cases are limited to ${MAX_CASES}. Delete or export some first.`);
273
278
  const record = createCase({ ...input, domain }, nowIso);
279
+ if (cases.some(existingCase => existingCase.id === record.id))
280
+ throw new Error('A unique Case ID could not be allocated. No data was changed; try again.');
274
281
  return { cases: [record, ...cases], record, created: true };
275
282
  }
283
+ /** Start a separate incident; reuse only the deliberately selected observation. */
284
+ export function createCaseIncident(cases, input, nowIso) {
285
+ const domain = normalizeDomain(input.domain);
286
+ const title = normalizeCaseObjective(input.title);
287
+ if (!domain)
288
+ throw new Error('Enter a valid domain for the incident.');
289
+ if (!title)
290
+ throw new Error('Enter a title that distinguishes this incident.');
291
+ if (typeof input.title === 'string' && input.title.trim().length > MAX_CASE_OBJECTIVE_LENGTH)
292
+ throw new Error(`Incident titles are limited to ${MAX_CASE_OBJECTIVE_LENGTH} characters.`);
293
+ let evidence;
294
+ if (input.reuse) {
295
+ const source = cases.find(record => record.id === input.reuse?.caseId && record.domain === domain);
296
+ evidence = source?.evidenceHistory.find(snapshot => snapshot.id === input.reuse?.snapshotId);
297
+ if (!evidence)
298
+ throw new Error('The selected source observation is no longer available for this domain. No Case was created.');
299
+ }
300
+ return openOrCreateCase(cases, { domain, title, ...(evidence ? { evidence: structuredClone(evidence) } : {}) }, nowIso, { newIncident: true });
301
+ }
276
302
  /**
277
303
  * Applies a partial update to one case by id, bumping updatedAt. A `note` in
278
304
  * the patch is appended (respecting the per-case note bound); a `tags` array
@@ -293,6 +319,9 @@ export function updateCase(cases, id, patch, nowIso) {
293
319
  const current = cases[index];
294
320
  if (!current)
295
321
  throw new Error('That case no longer exists.');
322
+ if (patch.title !== undefined && patch.expectedTitle !== undefined && patch.expectedTitle !== (current.title ?? '')) {
323
+ throw new Error('The incident title changed after this draft was started. Reload and review the current title before saving; your draft has not been applied.');
324
+ }
296
325
  let notes = current.notes;
297
326
  if (patch.note !== undefined) {
298
327
  const body = normalizeNoteBody(patch.note);
@@ -352,6 +381,12 @@ export function updateCase(cases, id, patch, nowIso) {
352
381
  }
353
382
  let observedEffects = normalizeCaseObservedEffectHistory(current.observedEffects, current.updatedAt, pinIds, new Set(sightings.map((item) => item.id)));
354
383
  if (patch.observedEffectReview !== undefined) {
384
+ const review = objectRecord(patch.observedEffectReview);
385
+ const context = readCaseRecheckAnswerContext(review.recheck);
386
+ if (context) {
387
+ assertCurrentRecheckQuestion(context, assertions);
388
+ assertRecheckNonReproduction(review.state, context, String(review.completeness), evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), typeof review.observedAt === 'string' ? review.observedAt : null);
389
+ }
355
390
  observedEffects = appendCaseObservedEffectReview(observedEffects, patch.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)));
356
391
  }
357
392
  let closures = normalizeCaseClosureHistory(current.closures, current.updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), {}, buildCaseClosureLinkContext(observedEffects, actions));
@@ -363,6 +398,7 @@ export function updateCase(cases, id, patch, nowIso) {
363
398
  }
364
399
  const record = {
365
400
  ...current,
401
+ title: patch.title === undefined ? current.title ?? '' : normalizeCaseObjective(patch.title),
366
402
  status: patch.closure !== undefined
367
403
  ? 'resolved'
368
404
  : patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
@@ -394,8 +430,8 @@ export function updateCase(cases, id, patch, nowIso) {
394
430
  }
395
431
  /**
396
432
  * Records one reviewed conclusion as a single pure Case mutation. The selected
397
- * observations become bounded Case pins before the decision is created, so the
398
- * decision cannot point at absent evidence. Counterevidence is retained as a
433
+ * observations become bounded Case pins or reference existing retained pins,
434
+ * so the decision cannot point at absent evidence. Counterevidence is retained as a
399
435
  * separate resolved contradiction assertion rather than being hidden inside a
400
436
  * favourable disposition.
401
437
  */
@@ -422,14 +458,36 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
422
458
  if (!current)
423
459
  throw new Error('That case no longer exists.');
424
460
  const existingPinIds = new Set(current.evidencePins.map((pin) => pin.id));
425
- const withPins = updateCase(cases, id, {
426
- evidencePins: input.evidence.map((item) => item.pin),
427
- }, now);
461
+ const selectedExisting = new Set();
462
+ for (const item of input.evidence) {
463
+ if (Object.hasOwn(item, 'pin') === Object.hasOwn(item, 'pinId'))
464
+ throw new Error('Select one new pin or one retained pin ID for each conclusion fact.');
465
+ if (Object.hasOwn(item, 'pinId')) {
466
+ if (typeof item.pinId !== 'string' || !existingPinIds.has(item.pinId))
467
+ throw new Error('The selected conclusion pin is no longer retained in this Case.');
468
+ if (selectedExisting.has(item.pinId))
469
+ throw new Error('Select each retained conclusion pin once.');
470
+ selectedExisting.add(item.pinId);
471
+ }
472
+ }
473
+ const newEvidence = input.evidence.filter(item => Object.hasOwn(item, 'pin'));
474
+ const withPins = newEvidence.length
475
+ ? updateCase(cases, id, { evidencePins: newEvidence.map(item => item.pin) }, now)
476
+ : { cases, record: current };
428
477
  const addedPins = withPins.record.evidencePins.filter((pin) => !existingPinIds.has(pin.id));
429
- if (addedPins.length !== input.evidence.length) {
478
+ if (addedPins.length !== newEvidence.length) {
430
479
  throw new Error('The selected conclusion evidence could not be retained completely.');
431
480
  }
432
- const supportingPinIds = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
481
+ let nextAdded = 0;
482
+ const selectedPins = input.evidence.map(item => {
483
+ const pin = Object.hasOwn(item, 'pinId')
484
+ ? withPins.record.evidencePins.find(pin => pin.id === item.pinId)
485
+ : addedPins[nextAdded++];
486
+ if (!pin)
487
+ throw new Error('The selected conclusion evidence could not be retained completely.');
488
+ return pin;
489
+ });
490
+ const supportingPinIds = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
433
491
  let concluded = updateCase(withPins.cases, id, {
434
492
  disposition,
435
493
  reviewReasonCode,
@@ -439,7 +497,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
439
497
  evidencePinIds: supportingPinIds,
440
498
  },
441
499
  }, now);
442
- const contradictionRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
500
+ const contradictionRelations = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
443
501
  ? [{ evidencePinId: pin.id, stance: 'contradicts' }]
444
502
  : []));
445
503
  if (contradictionRelations.length) {
@@ -454,7 +512,7 @@ export function recordCaseConclusion(cases, id, input, nowIso) {
454
512
  },
455
513
  }, now);
456
514
  }
457
- const unresolvedRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
515
+ const unresolvedRelations = selectedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
458
516
  ? [{ evidencePinId: pin.id, stance: 'unresolved' }]
459
517
  : []));
460
518
  if (unresolvedRelations.length) {
@@ -505,6 +563,8 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
505
563
  const current = cases.find((item) => item.id === id);
506
564
  if (!current)
507
565
  throw new Error('That case no longer exists.');
566
+ if (input.recheck && input.observationHostname !== input.recheck.targetHostname)
567
+ throw new Error('This recheck concerns a different or unknown hostname. Recollect the saved question target before recording its answer.');
508
568
  const beforePinIds = new Set(current.evidencePins.map((pin) => pin.id));
509
569
  const withPin = updateCase(cases, id, {
510
570
  evidencePin: {
@@ -516,6 +576,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
516
576
  sourceState: 'reviewed',
517
577
  observedAt: input.observedAt,
518
578
  collectionDepth: input.collectionDepth,
579
+ observationHostname: input.observationHostname,
519
580
  completeness: input.completeness,
520
581
  truncated: false,
521
582
  limitations: input.limitations,
@@ -534,6 +595,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
534
595
  evidencePinId: comparisonPin.id,
535
596
  followUpAt: input.followUpAt,
536
597
  limitations: input.limitations,
598
+ ...(input.recheck ? { recheck: input.recheck } : {}),
537
599
  },
538
600
  }, now);
539
601
  }