@slicedearth/whoisleuth-cli 2.4.0 → 2.6.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 (340) hide show
  1. package/DISCLOSURE +1 -1
  2. package/NOTICE +2 -2
  3. package/cli/argument-values.mjs +55 -0
  4. package/cli/arguments.mjs +24 -470
  5. package/cli/artifact-validation/case-response.mjs +67 -9
  6. package/cli/artifact-validation/lookup-evidence.mjs +18 -4
  7. package/cli/artifact-validation/signed-review.mjs +61 -64
  8. package/cli/artifact-verify.mjs +11 -2
  9. package/cli/assurance-arguments.mjs +56 -0
  10. package/cli/assurance-command-definitions.mjs +179 -0
  11. package/cli/assurance-command-runner.mjs +1 -1
  12. package/cli/bounded-json.mjs +1 -1
  13. package/cli/bulk-checkpoint.mjs +1 -1
  14. package/cli/bulk-command-runner.mjs +6 -8
  15. package/cli/bulk.mjs +2 -3
  16. package/cli/case-command.mjs +99 -6
  17. package/cli/case-pack.mjs +19 -4
  18. package/cli/ci-report.mjs +6 -3
  19. package/cli/collection-arguments.mjs +123 -0
  20. package/cli/collection-command-definitions.mjs +330 -0
  21. package/cli/collection-preflight.mjs +8 -1
  22. package/cli/command-argument-grammar.mjs +8 -210
  23. package/cli/command-definition.mjs +330 -0
  24. package/cli/command-reference.mjs +55 -1388
  25. package/cli/command-seeds.mjs +18 -0
  26. package/cli/compare.mjs +1 -1
  27. package/cli/config-profile.mjs +1 -1
  28. package/cli/ct-event-intake.mjs +4 -4
  29. package/cli/discover.mjs +2 -2
  30. package/cli/discovery-observation-snapshot.mjs +2 -2
  31. package/cli/discovery-scan-command-runner.mjs +7 -9
  32. package/cli/discovery-scan.mjs +1 -1
  33. package/cli/discovery-snapshot.mjs +2 -2
  34. package/cli/discovery-workflow.mjs +4 -4
  35. package/cli/domain-control-monitor.mjs +6 -6
  36. package/cli/domain-control-observations.mjs +2 -1
  37. package/cli/domain-feed.mjs +61 -0
  38. package/cli/errors.mjs +5 -6
  39. package/cli/evidence-arguments.mjs +46 -0
  40. package/cli/evidence-command-definitions.mjs +69 -0
  41. package/cli/evidence-command-runner.mjs +2 -3
  42. package/cli/evidence-signing.mjs +3 -2
  43. package/cli/external-observation-mapping.mjs +2 -2
  44. package/cli/formatters/json.mjs +1 -13
  45. package/cli/formatters/terminal-command-formats.mjs +6 -0
  46. package/cli/formatters/terminal-lookup.mjs +6 -0
  47. package/cli/history-arguments.mjs +39 -0
  48. package/cli/history-command-definitions.mjs +88 -0
  49. package/cli/indicator-set-command.mjs +32 -0
  50. package/cli/inline-command-families.mjs +6 -47
  51. package/cli/inline-command-runner.mjs +4 -31
  52. package/cli/input.mjs +2 -2
  53. package/cli/intake-command.mjs +147 -0
  54. package/cli/interchange-report.mjs +4 -2
  55. package/cli/investigation-artifacts.mjs +3 -4
  56. package/cli/investigation-package-review.mjs +2 -0
  57. package/cli/investigation-plan.mjs +32 -154
  58. package/cli/investigation-recipes.mjs +177 -0
  59. package/cli/investigation-run.mjs +78 -12
  60. package/cli/local-mmdb-review.mjs +220 -17
  61. package/cli/lookalike-calibration.mjs +1 -1
  62. package/cli/lookup-brief.mjs +2 -2
  63. package/cli/lookup-command-runner.mjs +19 -35
  64. package/cli/lookup-diff.mjs +10 -9
  65. package/cli/lookup-plan.mjs +10 -14
  66. package/cli/lookup-reconcile.mjs +6 -5
  67. package/cli/lookup-timeline.mjs +7 -6
  68. package/cli/mail-header-review.mjs +24 -247
  69. package/cli/mail-review.mjs +9 -7
  70. package/cli/network-arguments.mjs +51 -0
  71. package/cli/network-command-definitions.mjs +130 -0
  72. package/cli/network-command-runner.mjs +43 -31
  73. package/cli/offline-evidence-review.mjs +85 -6
  74. package/cli/posture.mjs +1 -1
  75. package/cli/registry-fixture-scaffold.mjs +1 -1
  76. package/cli/retained-artifact-diff.mjs +8 -7
  77. package/cli/review-arguments.mjs +132 -0
  78. package/cli/review-command-definitions.mjs +279 -0
  79. package/cli/review-command-runner.mjs +72 -159
  80. package/cli/risk-calibration.mjs +14 -12
  81. package/cli/runner.mjs +20 -32
  82. package/cli/saved-lookup.mjs +2 -2
  83. package/cli/selected-input-worker.mjs +73 -0
  84. package/cli/sharing-review.mjs +5 -64
  85. package/cli/source-reliability.mjs +2 -2
  86. package/cli/support-arguments.mjs +49 -0
  87. package/cli/support-command-definitions.mjs +209 -0
  88. package/cli/support-command-runner.mjs +4 -4
  89. package/cli/watchlist-review.mjs +43 -0
  90. package/cli/workflow-arguments.mjs +100 -0
  91. package/cli/workflow-command-definitions.mjs +103 -0
  92. package/cli/workflow-command-runner.mjs +40 -1
  93. package/docs/cli-reference.md +27 -0
  94. package/docs/cli.md +322 -4
  95. package/docs/contextual-reviews.md +178 -0
  96. package/docs/infrastructure-observations.md +126 -0
  97. package/docs/offline-intake-context.md +213 -0
  98. package/lib/analyst-taxonomy.mjs +2 -40
  99. package/lib/application-version.mjs +1 -1
  100. package/lib/availability.mjs +13 -789
  101. package/lib/bounded-contract-normalizers.mjs +4 -0
  102. package/lib/bounded-json.mjs +2 -330
  103. package/lib/browser-library-profile.mjs +62 -84
  104. package/lib/candidate-provenance-bounds.mjs +2 -0
  105. package/lib/classify.mjs +1 -1
  106. package/lib/credential-form-attribution.mjs +136 -0
  107. package/lib/credential-surface-profile.mjs +6 -2
  108. package/lib/ct-query.mjs +2 -27
  109. package/lib/ct-response-bounds.mjs +2 -9
  110. package/lib/ct-search.mjs +83 -47
  111. package/lib/dmarc-discovery.mjs +62 -0
  112. package/lib/dns-convergence-review.mjs +21 -8
  113. package/lib/dns-delegation-health.mjs +35 -15
  114. package/lib/dns-inheritance-review.mjs +53 -63
  115. package/lib/dns-intelligence.mjs +27 -11
  116. package/lib/dns-query-coverage.mjs +40 -0
  117. package/lib/domain-assurance.mjs +1 -1
  118. package/lib/domain-change-packet.mjs +12 -13
  119. package/lib/domain-change-review.mjs +32 -14
  120. package/lib/domain-control-flight-recorder.mjs +103 -27
  121. package/lib/domain-control-manifest.mjs +25 -17
  122. package/lib/domain-posture-analysis.mjs +75 -7
  123. package/lib/domain-posture-parsers.mjs +9 -0
  124. package/lib/domain-posture.mjs +45 -25
  125. package/lib/domain-registration.mjs +392 -0
  126. package/lib/evidence-export-privacy.mjs +27 -17
  127. package/lib/evidence-export.mjs +3 -3
  128. package/lib/evidence-report.mjs +5 -1
  129. package/lib/external-intelligence-risk.mjs +2 -124
  130. package/lib/favicon.mjs +13 -4
  131. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  132. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  133. package/lib/generated/retire-browser-catalog.mjs +332 -19
  134. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  135. package/lib/html-signals.mjs +13 -2
  136. package/lib/http-evidence-bounds.mjs +2 -8
  137. package/lib/http-intelligence.mjs +13 -1
  138. package/lib/idn-confusable-policy.mjs +6 -4
  139. package/lib/idn-confusables.mjs +1 -1
  140. package/lib/interchange-fidelity-registry.mjs +10 -0
  141. package/lib/lookup-child-profile-contract.mjs +28 -11
  142. package/lib/lookup-contract-primitives.mjs +1 -1
  143. package/lib/lookup-diagnostics.mjs +1 -23
  144. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  145. package/lib/lookup-progress.mjs +50 -1
  146. package/lib/lookup-response-contract.mjs +70 -40
  147. package/lib/lookup-response.mjs +5 -15
  148. package/lib/lookup-source-progress.mjs +8 -22
  149. package/lib/lookup.mjs +1 -1
  150. package/lib/operation-budget.mjs +4 -0
  151. package/lib/opportunity-scoring.mjs +2 -107
  152. package/lib/outbound-identity.mjs +1 -1
  153. package/lib/page-fingerprints.mjs +18 -1
  154. package/lib/perceptual-hash-comparison.mjs +2 -34
  155. package/lib/perceptual-hash.mjs +36 -10
  156. package/lib/portable-generator.mjs +2 -29
  157. package/lib/project-metadata.mjs +2 -8
  158. package/lib/rdap-client.mjs +11 -7
  159. package/lib/registrable-domain.mjs +2 -56
  160. package/lib/registrar-standing-contract.mjs +1 -1
  161. package/lib/registrar-standing.mjs +9 -13
  162. package/lib/registry-capabilities.mjs +2 -2
  163. package/lib/registry-capability-catalogue.mjs +27 -19
  164. package/lib/risk-calibration-summary.mjs +2 -352
  165. package/lib/risk-scoring.mjs +2 -345
  166. package/lib/scoring-evidence-quality.mjs +2 -107
  167. package/lib/semantic-version.mjs +2 -54
  168. package/lib/service-binding-dns.mjs +46 -2
  169. package/lib/smtp-transport-review.mjs +48 -19
  170. package/lib/sslbl-certificates.generated.mjs +142 -139
  171. package/lib/static-html-analysis.mjs +32 -4
  172. package/lib/structured-data-identity.mjs +1 -1
  173. package/lib/technology-evidence-role.mjs +1 -51
  174. package/lib/threat-intelligence-manifests.mjs +1 -1
  175. package/lib/threat-intelligence-runtime.mjs +1 -1
  176. package/lib/threat-intelligence-targets.mjs +1 -1
  177. package/lib/threat-intelligence-types.mjs +2 -75
  178. package/lib/tls-intelligence.mjs +30 -5
  179. package/lib/typosquat-generator.mjs +11 -2
  180. package/lib/website-enrichment.mjs +404 -0
  181. package/lib/whois-authority.mjs +43 -5
  182. package/lib/whois-chain.mjs +12 -3
  183. package/lib/whois-common-formats.mjs +3 -1
  184. package/lib/whois-contacts.mjs +13 -2
  185. package/lib/whois-normalization.mjs +3 -1
  186. package/lib/whois-parser.mjs +5 -3
  187. package/package.json +15 -14
  188. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  189. package/packages/analysis/bounded-json.mjs +330 -0
  190. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  191. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  192. package/packages/analysis/ct-query.mjs +27 -0
  193. package/packages/analysis/ct-response-bounds.mjs +9 -0
  194. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  195. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  196. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  197. package/packages/analysis/opportunity-scoring.mjs +107 -0
  198. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  199. package/packages/analysis/portable-generator.mjs +29 -0
  200. package/packages/analysis/project-metadata.mjs +8 -0
  201. package/packages/analysis/registrable-domain.mjs +56 -0
  202. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  203. package/packages/analysis/risk-scoring.mjs +348 -0
  204. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  205. package/packages/analysis/semantic-version.mjs +54 -0
  206. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  207. package/packages/cases/case-containment-handoff.mjs +195 -0
  208. package/packages/cases/case-evidence-links.mjs +151 -0
  209. package/packages/cases/case-evidence-model.mjs +89 -12
  210. package/packages/cases/case-evidence-quality.mjs +5 -0
  211. package/packages/cases/case-export-input.mjs +7 -5
  212. package/packages/cases/case-incident-context.mjs +52 -0
  213. package/packages/cases/case-markdown.mjs +11 -0
  214. package/packages/cases/case-migration-model.mjs +40 -24
  215. package/packages/cases/case-object-coverage.mjs +33 -0
  216. package/packages/cases/case-packet-correction.mjs +106 -0
  217. package/packages/cases/case-recheck-model.mjs +124 -36
  218. package/packages/cases/case-record-contracts.mjs +1 -1
  219. package/packages/cases/case-record-core.mjs +1 -1
  220. package/packages/cases/case-record-operations.mjs +111 -118
  221. package/packages/cases/case-record-projection.mjs +23 -4
  222. package/packages/cases/case-record-version-input.mjs +41 -0
  223. package/packages/cases/case-report.mjs +93 -27
  224. package/packages/cases/case-requested-evidence.mjs +146 -0
  225. package/packages/cases/case-response-actions.mjs +159 -1
  226. package/packages/cases/case-response-model.mjs +16 -1
  227. package/packages/cases/case-response-object.mjs +119 -0
  228. package/packages/cases/case-response-outcomes.mjs +108 -20
  229. package/packages/cases/case-response-packet-render.mjs +35 -12
  230. package/packages/cases/case-response-packet.mjs +55 -13
  231. package/packages/cases/case-response-records.mjs +0 -1
  232. package/packages/cases/case-response-review-inputs.mjs +60 -8
  233. package/packages/cases/case-storage-model.mjs +1 -1
  234. package/packages/cases/case-workflow-metadata.mjs +217 -0
  235. package/packages/cases/case-workflow-migration.mjs +43 -0
  236. package/packages/cases/http-summary.mjs +6 -1
  237. package/packages/comparison/favicon-similarity.mjs +1 -1
  238. package/packages/comparison/page-similarity.mjs +1 -1
  239. package/packages/comparison/relationship-evidence.mjs +21 -9
  240. package/packages/comparison/relationship-provenance.mjs +7 -1
  241. package/packages/contracts/analyst-interchange.mjs +35 -15
  242. package/packages/contracts/candidate-watch-review.mjs +54 -0
  243. package/packages/contracts/case-portability.mjs +360 -49
  244. package/packages/contracts/cli-command-semantics.mjs +5 -0
  245. package/packages/contracts/cli-grammar.mjs +6 -0
  246. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  247. package/packages/contracts/context-review.mjs +25 -0
  248. package/packages/contracts/document-intake.mjs +11 -0
  249. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  250. package/packages/contracts/domain-control-manifest.mjs +1 -1
  251. package/packages/contracts/domain-control-monitor.mjs +13 -2
  252. package/packages/contracts/domain-control-review.mjs +36 -15
  253. package/packages/contracts/external-observation-interchange.mjs +96 -28
  254. package/packages/contracts/har-review.mjs +2 -0
  255. package/packages/contracts/identity-events.mjs +4 -0
  256. package/packages/contracts/investigation-run.mjs +2 -0
  257. package/packages/contracts/ip-address.mjs +10 -0
  258. package/packages/contracts/lookup-evidence.mjs +3 -2
  259. package/packages/contracts/mail-authentication.mjs +5 -0
  260. package/packages/contracts/message-intake.mjs +37 -0
  261. package/packages/contracts/offline-comparison.mjs +4 -1
  262. package/packages/contracts/passive-mail.mjs +10 -0
  263. package/packages/contracts/risk-calibration.mjs +44 -29
  264. package/packages/contracts/schema-lifecycle.mjs +3 -1
  265. package/packages/contracts/web-capture.mjs +2 -2
  266. package/packages/contracts/workspace-portability.mjs +144 -28
  267. package/packages/evidence/collection-quality.mjs +67 -0
  268. package/packages/evidence/credential-form-attribution.mjs +60 -0
  269. package/packages/evidence/dns-query-outcome.mjs +72 -0
  270. package/packages/evidence/email-recipient.mjs +18 -0
  271. package/packages/evidence/image-regions.mjs +15 -0
  272. package/packages/evidence/lookup-target.mjs +5 -1
  273. package/packages/evidence/observation.mjs +2 -0
  274. package/packages/evidence/png-pixels.mjs +117 -0
  275. package/packages/evidence/public-address-policy.mjs +83 -0
  276. package/packages/evidence/record-identity.mjs +60 -16
  277. package/packages/evidence/technology-evidence-role.mjs +51 -0
  278. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  279. package/packages/interchange/external-findings-import.mjs +36 -29
  280. package/packages/interchange/json-output.mjs +14 -0
  281. package/packages/interchange/managed-indicator-export.mjs +36 -0
  282. package/packages/interchange/managed-indicator-set.mjs +223 -0
  283. package/packages/interchange/misp-indicator-export.mjs +93 -0
  284. package/packages/interchange/sharing-policy.mjs +56 -0
  285. package/packages/interchange/stix-indicator-export.mjs +129 -0
  286. package/packages/interchange/web-capture-import.mjs +22 -15
  287. package/packages/investigation/acquisition-decision-packet.mjs +79 -1
  288. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  289. package/packages/investigation/capture-coverage.mjs +57 -0
  290. package/packages/investigation/connector-provenance-review.mjs +100 -0
  291. package/packages/investigation/context-review.mjs +35 -0
  292. package/packages/investigation/document-intake.mjs +87 -0
  293. package/packages/investigation/docx-intake.mjs +133 -0
  294. package/packages/investigation/domain-history-review.mjs +132 -0
  295. package/packages/investigation/domain-transition-review.mjs +49 -0
  296. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  297. package/packages/investigation/har-intake.mjs +68 -0
  298. package/packages/investigation/identity-event-intake.mjs +131 -0
  299. package/packages/investigation/identity-incident-review.mjs +39 -0
  300. package/packages/investigation/incident-sequence-review.mjs +58 -0
  301. package/packages/investigation/infrastructure-observation.mjs +234 -0
  302. package/packages/investigation/intake-context.mjs +284 -0
  303. package/packages/investigation/intake-indicators.mjs +116 -0
  304. package/packages/investigation/intake-phones.mjs +155 -0
  305. package/packages/investigation/intake-report.mjs +20 -0
  306. package/packages/investigation/investigation-manifest.mjs +35 -9
  307. package/packages/investigation/link-intake.mjs +209 -0
  308. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  309. package/packages/investigation/mail-authentication-review.mjs +222 -0
  310. package/packages/investigation/mail-header-identity.mjs +209 -0
  311. package/packages/investigation/message-intake.mjs +235 -0
  312. package/packages/investigation/page-behaviour.mjs +113 -0
  313. package/packages/investigation/pdf-intake.mjs +155 -0
  314. package/packages/investigation/platform-continuity-review.mjs +60 -0
  315. package/packages/investigation/qr-intake.mjs +56 -0
  316. package/packages/investigation/requested-action-hints.mjs +11 -0
  317. package/packages/investigation/selected-input-review.mjs +17 -0
  318. package/packages/investigation/storefront-review.mjs +70 -0
  319. package/packages/monitoring/domain-feed.mjs +321 -0
  320. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  321. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  322. package/packages/workspace/brand-profile-model.mjs +48 -4
  323. package/packages/workspace/bulk-review-model.mjs +28 -26
  324. package/packages/workspace/bulk-session-model.mjs +27 -13
  325. package/packages/workspace/campaign-model.mjs +1 -1
  326. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  327. package/packages/workspace/detection-rule-model.mjs +11 -3
  328. package/packages/workspace/hostile-input.mjs +1 -1
  329. package/packages/workspace/investigation-guide.mjs +2 -2
  330. package/packages/workspace/investigation-template-model.mjs +26 -2
  331. package/packages/workspace/page-baseline.mjs +2 -2
  332. package/packages/workspace/relationship-observation-model.mjs +17 -13
  333. package/packages/workspace/shortlist-model.mjs +24 -7
  334. package/packages/workspace/watchlist-history.mjs +59 -16
  335. package/packages/workspace/watchlist-store.mjs +152 -11
  336. package/packages/workspace/website-snapshot-model.mjs +28 -3
  337. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  338. package/packages/workspace/workspace-archive.mjs +5 -4
  339. package/third-party-notices.txt +549 -16
  340. package/lib/hostname.mjs +0 -2
@@ -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
  }
@@ -0,0 +1,33 @@
1
+ import { retainedCaseResponseObjects, sameCaseResponseObject } from "./case-response-object.mjs";
2
+ export function buildCaseIncidentCoverage(record) {
3
+ const objects = new Map();
4
+ const explicit = retainedCaseResponseObjects(record);
5
+ for (const target of record.workflowMetadata?.incidentTargets ?? []) {
6
+ const matching = explicit.filter(object => object.incidentTargetId === target.id && object.identifier === target.url);
7
+ for (const object of matching.length ? matching : [{ kind: 'other', identifier: target.url, incidentTargetId: target.id }])
8
+ objects.set(JSON.stringify(object), { target, object, targetRetained: true });
9
+ }
10
+ for (const object of explicit)
11
+ if (!objects.has(JSON.stringify(object)))
12
+ objects.set(JSON.stringify(object), {
13
+ object, targetRetained: false,
14
+ target: { id: object.incidentTargetId ?? `${object.kind}-${object.identifier}`, url: object.identifier, state: 'open', createdAt: record.createdAt ?? '', updatedAt: record.updatedAt ?? '' },
15
+ });
16
+ return [...objects.values()].map(({ target, object, targetRetained }) => {
17
+ const actions = (record.actions ?? []).filter(action => action.responseObjects?.some(bound => sameCaseResponseObject(bound, object)));
18
+ const reviews = (record.observedEffects?.reviews ?? []).filter(review => sameCaseResponseObject(review.responseObject, object));
19
+ const latestAt = Math.max(...reviews.map(review => Date.parse(review.observedAt)));
20
+ const latest = reviews.filter(review => Date.parse(review.observedAt) === latestAt);
21
+ return { target, targetRetained, responseObject: object,
22
+ hostname: object.kind === 'domain' || object.kind === 'hostname' ? object.identifier : new URL(object.identifier).hostname,
23
+ actionCoverage: actions.length ? 'bound' : 'unknown',
24
+ actionIds: actions.map(action => action.id),
25
+ providerEvents: (record.actions ?? []).flatMap(action => action.history.filter(event => event.applied
26
+ && event.responseObjects?.some(bound => sameCaseResponseObject(bound, object))
27
+ && (event.providerOutcome || event.objectOutcome || event.nextState === 'acknowledged'))
28
+ .map(event => ({ actionId: action.id, eventId: event.id, state: event.nextState, outcome: event.objectOutcome ?? event.providerOutcome, occurredAt: event.occurredAt, sourceClass: event.sourceClass }))),
29
+ observationCoverage: !reviews.length ? 'unknown' : new Set(latest.map(review => `${review.state}\u0000${review.objectOutcome ?? ''}`)).size > 1 ? 'ambiguous' : 'available',
30
+ reviews,
31
+ closures: (record.closures?.records ?? []).filter(closure => sameCaseResponseObject(closure.responseObject, object)), };
32
+ });
33
+ }
@@ -0,0 +1,106 @@
1
+ // Local delivery linkage, not authentication, external acceptance or nonrepudiation.
2
+ import { array, enumeration, exact, HEX_DIGEST_RE, iso, text } from "../evidence/artifact-structure.mjs";
3
+ import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
4
+ import { MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS } from "../contracts/case-portability.mjs";
5
+ import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
6
+ import { readCaseResponseObjects, sameCaseResponseObject } from "./case-response-object.mjs";
7
+ import { RESPONSE_PACKET_PROFILE_IDS } from "./case-response-packet-vocabulary.mjs";
8
+ export const CASE_DELIVERY_RECEIPT_LIMITATION = 'This retained local receipt identifies canonical packet JSON and analyst-recorded delivery, not delivered message or PDF bytes, external receipt, factual truth, authorship authentication or provider acceptance.';
9
+ const id = (value, label) => {
10
+ const result = text(value, label, 64);
11
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(result))
12
+ throw new TypeError(`${label} is invalid.`);
13
+ return result;
14
+ };
15
+ const digest = (value) => {
16
+ if (typeof value !== 'string' || !HEX_DIGEST_RE.test(value))
17
+ throw new TypeError('Select an exact recorded packet digest.');
18
+ return value;
19
+ };
20
+ const packetVersion = (value) => {
21
+ if (!SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS.some(version => version === value))
22
+ throw new TypeError('The recorded packet version is unsupported.');
23
+ return value;
24
+ };
25
+ const statement = (value, label, required = true) => {
26
+ const result = text(value, label, MAX_RESPONSE_RATIONALE_LENGTH, !required).trim();
27
+ if (required && !result)
28
+ throw new TypeError(`${label} is required.`);
29
+ return result;
30
+ };
31
+ export function readCaseDeliveryPacketReceipt(value) {
32
+ if (value === undefined)
33
+ return undefined;
34
+ assertWorkspaceInputGraph(value, 'Delivery packet receipt');
35
+ const row = exact(value, ['version', 'caseId', 'actionId', 'target', 'packetDigestSha256', 'packetVersion', 'packetGeneratedAt', 'recipient', 'profile', 'responseObjects'], 'Delivery packet receipt');
36
+ if (row.version !== 1)
37
+ throw new TypeError('The delivery receipt version is unsupported.');
38
+ const target = text(row.target, 'Receipt target', 253);
39
+ if (!isValidAsciiHostname(target) || target !== target.toLowerCase())
40
+ throw new TypeError('The delivery receipt target is invalid.');
41
+ iso(row.packetGeneratedAt, 'Packet generation time');
42
+ const recipient = text(row.recipient, 'Receipt recipient', MAX_RESPONSE_RECIPIENT_LENGTH).trim();
43
+ if (!recipient)
44
+ throw new TypeError('The receipt recipient is required.');
45
+ const responseObjects = readCaseResponseObjects(row.responseObjects);
46
+ if (!responseObjects?.length)
47
+ throw new TypeError('The receipt requires an explicit nonempty delivered object scope.');
48
+ return Object.freeze({ version: 1, caseId: id(row.caseId, 'Receipt Case'), actionId: id(row.actionId, 'Receipt action'), target,
49
+ packetDigestSha256: digest(row.packetDigestSha256), packetVersion: packetVersion(row.packetVersion), packetGeneratedAt: row.packetGeneratedAt,
50
+ recipient, profile: enumeration(row.profile, RESPONSE_PACKET_PROFILE_IDS, 'Receipt audience'), responseObjects });
51
+ }
52
+ export function readCasePacketCorrection(value) {
53
+ if (value === undefined)
54
+ return undefined;
55
+ assertWorkspaceInputGraph(value, 'Packet correction');
56
+ const row = exact(value, ['version', 'purpose', 'deliveryEventId', 'packetDigestSha256', 'packetVersion', 'reason', 'previousStatement', 'correctedStatement', 'evidencePinIds', 'profile'], 'Packet correction');
57
+ if (row.version !== 1)
58
+ throw new TypeError('The correction version is unsupported.');
59
+ const purpose = enumeration(row.purpose, ['correction', 'retraction_request'], 'Correction purpose');
60
+ const pins = array(row.evidencePinIds, 'Correction evidence', MAX_DECISION_PIN_REFERENCES).map(value => id(value, 'Correction pin'));
61
+ if (!pins.length || new Set(pins).size !== pins.length)
62
+ throw new TypeError('Select distinct retained correction evidence pins.');
63
+ return Object.freeze({ version: 1, purpose, deliveryEventId: id(row.deliveryEventId, 'Original delivery event'),
64
+ packetDigestSha256: digest(row.packetDigestSha256), packetVersion: packetVersion(row.packetVersion),
65
+ reason: statement(row.reason, 'Correction reason'), previousStatement: statement(row.previousStatement, 'Previous statement'),
66
+ correctedStatement: statement(row.correctedStatement, 'Corrected statement', purpose === 'correction'),
67
+ evidencePinIds: Object.freeze(pins), profile: enumeration(row.profile, RESPONSE_PACKET_PROFILE_IDS, 'Correction audience') });
68
+ }
69
+ export function assertDeliveryPacketReceipt(receipt, event, actionId) {
70
+ if (!receipt)
71
+ return;
72
+ if (receipt.actionId !== actionId || !['authorised', 'submitted'].includes(event.previousState ?? '') || event.nextState !== 'submitted' || event.sourceClass !== 'analyst'
73
+ || event.reference !== `response-packet-sha256:${receipt.packetDigestSha256}`
74
+ || JSON.stringify(event.responseObjects ?? []) !== JSON.stringify(receipt.responseObjects)
75
+ || (event.occurredAt && Date.parse(event.occurredAt) < Date.parse(receipt.packetGeneratedAt)))
76
+ throw new TypeError('The packet receipt must match its exact analyst-recorded delivery and object scope.');
77
+ }
78
+ export function correctionDelivery(actions, action, context) {
79
+ const correction = action.correction;
80
+ if (!correction)
81
+ return null;
82
+ const original = actions.find(item => item.id === action.originActionId);
83
+ const event = original?.history.find(item => item.id === correction.deliveryEventId);
84
+ const receipt = event?.packetReceipt;
85
+ if (!original || !event?.applied || !receipt)
86
+ throw new TypeError('An exact-linked correction requires the retained receipt of the explicitly selected delivery. Legacy digest-only or omitted receipts cannot authorise it.');
87
+ assertDeliveryPacketReceipt(receipt, event, original.id);
88
+ if (receipt.recipient === '[redacted]' || action.recipient === '[redacted]' || receipt.recipient !== action.recipient
89
+ || original.recipient !== receipt.recipient || correction.profile !== receipt.profile
90
+ || correction.packetDigestSha256 !== receipt.packetDigestSha256 || correction.packetVersion !== receipt.packetVersion
91
+ || !action.responseObjects?.length || action.responseObjects.some(object => !receipt.responseObjects.some(previous => sameCaseResponseObject(previous, object))))
92
+ throw new TypeError('Correction recipient, version, digest, audience and object subset must match the selected delivered packet.');
93
+ if (context && (receipt.caseId !== context.caseId || receipt.target !== context.target || (context.profile && context.profile !== correction.profile)
94
+ || (context.selectedPinIds && correction.evidencePinIds.some(pin => !context.selectedPinIds.has(pin)))))
95
+ throw new TypeError('Review the current Case target, audience and every retained correction pin before preparation.');
96
+ return { original, event, receipt };
97
+ }
98
+ /** Called only with the exact completed packet; never uses a later editable draft. */
99
+ export function deliveryReceiptForPacket(packet) {
100
+ const action = packet.escalationHistory.find(action => action.actionId === packet.actionBinding.selectedActionId);
101
+ if (!action || !packet.recipientRoute || packet.authorisation.status !== 'authorised' || !action.responseObjects?.length)
102
+ throw new TypeError('A retained exact receipt requires an authorised action-bound packet with explicit object scope.');
103
+ return readCaseDeliveryPacketReceipt({ version: 1, caseId: packet.case.id, actionId: action.actionId, target: packet.case.domain,
104
+ packetDigestSha256: packet.integrity.digestSha256, packetVersion: packet.schemaVersion, packetGeneratedAt: packet.generatedAt,
105
+ recipient: packet.recipientRoute.contact, profile: packet.profile.id, responseObjects: action.responseObjects });
106
+ }
@@ -1,14 +1,20 @@
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' });
1
+ import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
2
+ import { INCIDENT_CASE_SCHEMA_VERSION, MAX_RESPONSE_RATIONALE_LENGTH, } from "../contracts/case-portability.mjs";
3
+ import { enumeration, exactOptional, text } from "../evidence/artifact-structure.mjs";
4
+ import { readCaseResponseObject, sameCaseResponseObject } from "./case-response-object.mjs";
5
+ import { casePinHasCompleteObservation } from "./case-evidence-quality.mjs";
6
+ export const CASE_RECHECK_CONDITIONS = Object.freeze({
7
+ unknown: 'Not established',
8
+ comparable: 'Comparable for this question',
9
+ different: 'Different conditions',
10
+ });
5
11
  function reference(value, label) {
6
12
  const id = text(value, label, 64);
7
13
  if (!/^[A-Za-z0-9_-]+$/u.test(id))
8
14
  throw new TypeError(`${label} is invalid.`);
9
15
  return id;
10
16
  }
11
- function contextFields(item) {
17
+ function contextFields(item, sourceVersion) {
12
18
  const targetHostname = text(item.targetHostname, 'Recheck target hostname', 253);
13
19
  if (targetHostname !== targetHostname.toLowerCase() || !isValidAsciiHostname(targetHostname)) {
14
20
  throw new TypeError('Use one normalised hostname for the recheck target, without a path or query.');
@@ -16,8 +22,17 @@ function contextFields(item) {
16
22
  const conditions = text(item.conditions, 'Recheck comparison conditions', MAX_RESPONSE_RATIONALE_LENGTH);
17
23
  if (!conditions.trim())
18
24
  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') });
25
+ const responseObject = readCaseResponseObject(item.responseObject, sourceVersion);
26
+ if (responseObject && (responseObject.kind === 'domain' || responseObject.kind === 'hostname'
27
+ ? responseObject.identifier : new URL(responseObject.identifier).hostname) !== targetHostname) {
28
+ throw new TypeError('The recheck target hostname must match the exact object snapshot.');
29
+ }
30
+ return Object.freeze({
31
+ targetHostname,
32
+ conditions,
33
+ baselinePinId: item.baselinePinId === null ? null : reference(item.baselinePinId, 'Recheck baseline pin'),
34
+ ...(responseObject ? { responseObject } : {}),
35
+ });
21
36
  }
22
37
  /** Optional current-format context; historical records have no invented question. */
23
38
  export function readCaseRecheckContext(value, sourceVersion) {
@@ -25,67 +40,140 @@ export function readCaseRecheckContext(value, sourceVersion) {
25
40
  return undefined;
26
41
  if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
27
42
  throw new TypeError('This Case format cannot contain structured recheck questions.');
28
- return contextFields(exact(value, ['targetHostname', 'baselinePinId', 'conditions'], 'Recheck question context'));
43
+ return contextFields(exactOptional(value, ['targetHostname', 'baselinePinId', 'conditions'], ['responseObject'], 'Recheck question context'), sourceVersion);
29
44
  }
30
45
  export function readCaseRecheckAnswerContext(value, sourceVersion) {
31
46
  if (value === undefined)
32
47
  return undefined;
33
48
  if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
34
49
  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');
50
+ const item = exactOptional(value, ['targetHostname', 'baselinePinId', 'conditions', 'questionId', 'question', 'conditionsMatch'], ['responseObject'], 'Recheck answer context');
36
51
  const question = text(item.question, 'Recheck question', MAX_RESPONSE_RATIONALE_LENGTH);
37
52
  if (!question.trim())
38
53
  throw new TypeError('A recheck answer requires its original question.');
39
- return Object.freeze({ ...contextFields(item), questionId: reference(item.questionId, 'Recheck question ID'),
54
+ return Object.freeze({
55
+ ...contextFields(item, sourceVersion),
56
+ questionId: reference(item.questionId, 'Recheck question ID'),
40
57
  question,
41
- conditionsMatch: enumeration(item.conditionsMatch, Object.keys(CASE_RECHECK_CONDITIONS), 'Recheck condition comparison') });
58
+ conditionsMatch: enumeration(item.conditionsMatch, Object.keys(CASE_RECHECK_CONDITIONS), 'Recheck condition comparison'),
59
+ });
42
60
  }
43
61
  export function caseRecheckQuestions(assertions) {
44
- return assertions.filter(item => item.kind === 'next_step' && item.recheck && item.state === 'open');
62
+ return assertions.filter((item) => item.kind === 'next_step' && item.recheck && item.state === 'open');
45
63
  }
46
64
  export function caseRecheckAnswerContext(question, conditionsMatch) {
47
65
  if (question.kind !== 'next_step' || !question.recheck)
48
66
  throw new TypeError('Select a saved recheck question.');
49
- return readCaseRecheckAnswerContext({ ...question.recheck, questionId: question.id, question: question.statement, conditionsMatch });
67
+ return readCaseRecheckAnswerContext({
68
+ ...question.recheck,
69
+ questionId: question.id,
70
+ question: question.statement,
71
+ conditionsMatch,
72
+ });
73
+ }
74
+ /** Shared read projection; each form retains its draft and existing save coordinator. */
75
+ export function selectCaseRecheckQuestion(assertions, selection) {
76
+ const questions = caseRecheckQuestions(assertions);
77
+ const question = questions.find((item) => item.id === selection.questionId);
78
+ return {
79
+ questions,
80
+ context: question ? caseRecheckAnswerContext(question, selection.conditionsMatch) : undefined,
81
+ stale: Boolean(selection.questionId) && (!question || question.updatedAt !== selection.questionUpdatedAt),
82
+ };
50
83
  }
51
84
  /** A saved answer is a snapshot, not a live reference to an editable plan. */
52
85
  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)) {
86
+ const question = caseRecheckQuestions(assertions).find((item) => item.id === answer.questionId);
87
+ if (!question ||
88
+ JSON.stringify(caseRecheckAnswerContext(question, answer.conditionsMatch)) !==
89
+ JSON.stringify(answer)) {
55
90
  throw new Error('The recheck question changed or was resolved. Refresh and review its current conditions before saving this answer.');
56
91
  }
57
92
  }
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);
93
+ const RECHECK_COMPARISON_MESSAGES = {
94
+ different_conditions: 'The comparison conditions differ.',
95
+ unconfirmed_conditions: 'Comparable conditions have not been confirmed.',
96
+ missing_baseline: 'The baseline evidence is no longer available.',
97
+ current_target_mismatch: 'The current evidence concerns a different or unknown hostname.',
98
+ incomplete_current: 'The current source does not establish a complete observation.',
99
+ later_observation_needed: 'A later source observation is needed; reviewing the baseline again is not a recheck.',
100
+ baseline_target_mismatch: 'The baseline concerns a different hostname.',
101
+ different_field_or_source: 'The observations use different fields or sources.',
102
+ missing_object_baseline: 'An object-specific baseline is required for this exact-object comparison.',
103
+ baseline_object_mismatch: 'The baseline does not explicitly concern this same object.',
104
+ current_object_mismatch: 'The current observation does not explicitly concern this same object.',
105
+ incomplete_object_baseline: 'The exact-object baseline must be a complete source observation.',
106
+ };
107
+ /** Admissibility depends on these reasons, not on presentation warnings. */
108
+ export function caseRecheckComparisonBlockers(context, pins, current) {
109
+ const blockers = [];
110
+ if (context.conditionsMatch !== 'comparable') {
111
+ blockers.push(context.conditionsMatch === 'different'
112
+ ? 'different_conditions'
113
+ : 'unconfirmed_conditions');
114
+ }
115
+ const baseline = pins.find((pin) => pin.id === context.baselinePinId);
116
+ if (context.responseObject) {
117
+ if (!baseline)
118
+ blockers.push('missing_object_baseline');
119
+ else if (!sameCaseResponseObject(baseline.responseObject, context.responseObject))
120
+ blockers.push('baseline_object_mismatch');
121
+ if (baseline && !casePinHasCompleteObservation(baseline))
122
+ blockers.push('incomplete_object_baseline');
123
+ if (!current || !sameCaseResponseObject(current.responseObject, context.responseObject))
124
+ blockers.push('current_object_mismatch');
125
+ }
64
126
  if (context.baselinePinId && !baseline)
65
- warnings.push('The baseline evidence is no longer available.');
127
+ blockers.push('missing_baseline');
66
128
  if (current) {
67
129
  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.');
130
+ blockers.push('current_target_mismatch');
131
+ if (!casePinHasCompleteObservation(current))
132
+ blockers.push('incomplete_current');
71
133
  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.');
134
+ const sameObservation = baseline.id === current.id;
135
+ const unknownObservationTime = !baseline.observedAt || !current.observedAt;
136
+ const nonLaterObservation = !unknownObservationTime &&
137
+ Date.parse(current.observedAt) <= Date.parse(baseline.observedAt);
138
+ if (sameObservation || unknownObservationTime || nonLaterObservation) {
139
+ blockers.push('later_observation_needed');
140
+ }
74
141
  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.');
142
+ blockers.push('baseline_target_mismatch');
143
+ const differentField = Boolean(baseline.field) && current.field !== baseline.field;
144
+ const differentSource = baseline.source !== current.source;
145
+ if (differentField || differentSource)
146
+ blockers.push('different_field_or_source');
78
147
  }
79
148
  }
80
- return warnings;
149
+ return blockers;
150
+ }
151
+ export function caseRecheckComparisonWarnings(context, pins, current) {
152
+ return caseRecheckComparisonBlockers(context, pins, current)
153
+ .map((reason) => RECHECK_COMPARISON_MESSAGES[reason]);
81
154
  }
82
155
  export function assertRecheckNonReproduction(state, context, completeness, pins, current, observedAt) {
83
156
  if (state !== 'not_reproduced')
84
157
  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)
158
+ const blockers = caseRecheckComparisonBlockers(context, pins, current);
159
+ const baseline = pins.find((pin) => pin.id === context.baselinePinId);
160
+ let laterIndependentObservationNeeded = false;
161
+ if (!current && baseline) {
162
+ const missingObservationTime = !baseline.observedAt || !observedAt;
163
+ const invalidObservationTime = observedAt ? !Number.isFinite(Date.parse(observedAt)) : false;
164
+ const nonLaterObservation = !missingObservationTime && Date.parse(observedAt) <= Date.parse(baseline.observedAt);
165
+ laterIndependentObservationNeeded = missingObservationTime || invalidObservationTime || nonLaterObservation;
166
+ }
167
+ if (completeness !== 'complete' || blockers.length || laterIndependentObservationNeeded) {
90
168
  throw new Error('Not reproduced requires a complete observation under comparable conditions. Record unavailable or describe the limited observation instead.');
169
+ }
170
+ }
171
+ export const COMPARATIVE_CASE_OBJECT_OUTCOMES = Object.freeze(['removed', 'restricted', 'suspended', 'delisted', 'transferred', 'restored']);
172
+ /** Technical state changes need an exact-object baseline; procedural dispute is distinct. */
173
+ export function assertCaseObjectObservationOutcome(outcome, context, completeness, pins, current, observedAt) {
174
+ if (!outcome || !COMPARATIVE_CASE_OBJECT_OUTCOMES.includes(outcome))
175
+ return;
176
+ if (!context?.responseObject)
177
+ throw new TypeError('An independent object state change requires a saved exact-object baseline question and comparable evidence.');
178
+ assertRecheckNonReproduction('not_reproduced', context, completeness, pins, current, observedAt);
91
179
  }
@@ -1,4 +1,4 @@
1
- import { ANALYST_REVIEW_REASONS } from "../../lib/analyst-taxonomy.mjs";
1
+ import { ANALYST_REVIEW_REASONS } from "../analysis/analyst-taxonomy.mjs";
2
2
  import { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, MAX_DOMAIN_LENGTH, 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, MAX_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, } from "../contracts/case-portability.mjs";
3
3
  import { CASE_DISPOSITIONS, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_STATUS, } from "./case-record-decisions.mjs";
4
4
  export { CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, MAX_CASE_BRAND_PROFILE_ID_CANDIDATES, MAX_CASE_BRAND_PROFILE_IDS, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_CASES, MAX_DOMAIN_LENGTH, 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, MAX_NOTE_LENGTH, MAX_NOTES_PER_CASE, MAX_TAG_LENGTH, MAX_TAGS_PER_CASE, };
@@ -4,7 +4,7 @@ import { CASE_REVIEW_REASONS, CASE_SOURCES, DEFAULT_DISPOSITION, DEFAULT_SOURCE,
4
4
  import { dispositionLabel, isValidDisposition, isValidStatus, statusLabel, } from "./case-record-decisions.mjs";
5
5
  import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
6
6
  import { PUBLISHED_V2_3_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
7
- import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
7
+ import { canonicalRegistrableDomain } from "../analysis/registrable-domain.mjs";
8
8
  // Forward-version policy (two distinct guarantees):
9
9
  // - A locally-stored envelope that declares a version greater than this is
10
10
  // still read best-effort on load (known fields kept, unknown dropped), but