@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/NOTICE +2 -2
  2. package/cli/argument-values.mjs +55 -0
  3. package/cli/arguments.mjs +24 -470
  4. package/cli/artifact-validation/case-response.mjs +21 -6
  5. package/cli/artifact-validation/lookup-evidence.mjs +3 -3
  6. package/cli/artifact-validation/signed-review.mjs +59 -13
  7. package/cli/artifact-verify.mjs +2 -0
  8. package/cli/assurance-arguments.mjs +56 -0
  9. package/cli/assurance-command-definitions.mjs +179 -0
  10. package/cli/assurance-command-runner.mjs +1 -1
  11. package/cli/bounded-json.mjs +1 -1
  12. package/cli/bulk-checkpoint.mjs +1 -1
  13. package/cli/bulk-command-runner.mjs +2 -2
  14. package/cli/bulk.mjs +2 -3
  15. package/cli/case-command.mjs +20 -3
  16. package/cli/case-pack.mjs +12 -2
  17. package/cli/ci-report.mjs +6 -3
  18. package/cli/collection-arguments.mjs +123 -0
  19. package/cli/collection-command-definitions.mjs +330 -0
  20. package/cli/collection-preflight.mjs +8 -1
  21. package/cli/command-argument-grammar.mjs +8 -210
  22. package/cli/command-definition.mjs +329 -0
  23. package/cli/command-reference.mjs +55 -1388
  24. package/cli/command-seeds.mjs +18 -0
  25. package/cli/compare.mjs +1 -1
  26. package/cli/config-profile.mjs +1 -1
  27. package/cli/ct-event-intake.mjs +4 -4
  28. package/cli/discover.mjs +2 -2
  29. package/cli/discovery-observation-snapshot.mjs +2 -2
  30. package/cli/discovery-scan-command-runner.mjs +3 -3
  31. package/cli/discovery-scan.mjs +1 -1
  32. package/cli/discovery-snapshot.mjs +2 -2
  33. package/cli/discovery-workflow.mjs +4 -4
  34. package/cli/domain-control-monitor.mjs +6 -6
  35. package/cli/domain-control-observations.mjs +2 -1
  36. package/cli/errors.mjs +5 -6
  37. package/cli/evidence-arguments.mjs +46 -0
  38. package/cli/evidence-command-definitions.mjs +69 -0
  39. package/cli/evidence-command-runner.mjs +2 -3
  40. package/cli/evidence-signing.mjs +3 -2
  41. package/cli/external-observation-mapping.mjs +2 -2
  42. package/cli/formatters/terminal-command-formats.mjs +6 -0
  43. package/cli/formatters/terminal-lookup.mjs +6 -0
  44. package/cli/history-arguments.mjs +39 -0
  45. package/cli/history-command-definitions.mjs +88 -0
  46. package/cli/indicator-set-command.mjs +33 -0
  47. package/cli/inline-command-families.mjs +6 -47
  48. package/cli/inline-command-runner.mjs +4 -31
  49. package/cli/input.mjs +2 -2
  50. package/cli/intake-command.mjs +107 -0
  51. package/cli/interchange-report.mjs +4 -2
  52. package/cli/investigation-artifacts.mjs +1 -1
  53. package/cli/investigation-plan.mjs +32 -154
  54. package/cli/investigation-recipes.mjs +177 -0
  55. package/cli/investigation-run.mjs +78 -12
  56. package/cli/local-mmdb-review.mjs +197 -17
  57. package/cli/lookalike-calibration.mjs +1 -1
  58. package/cli/lookup-brief.mjs +2 -2
  59. package/cli/lookup-command-runner.mjs +18 -35
  60. package/cli/lookup-diff.mjs +10 -9
  61. package/cli/lookup-plan.mjs +10 -14
  62. package/cli/lookup-reconcile.mjs +6 -5
  63. package/cli/lookup-timeline.mjs +7 -6
  64. package/cli/mail-header-review.mjs +24 -247
  65. package/cli/mail-review.mjs +9 -7
  66. package/cli/network-arguments.mjs +51 -0
  67. package/cli/network-command-definitions.mjs +130 -0
  68. package/cli/network-command-runner.mjs +43 -31
  69. package/cli/offline-evidence-review.mjs +39 -6
  70. package/cli/posture.mjs +1 -1
  71. package/cli/registry-fixture-scaffold.mjs +1 -1
  72. package/cli/retained-artifact-diff.mjs +8 -7
  73. package/cli/review-arguments.mjs +131 -0
  74. package/cli/review-command-definitions.mjs +273 -0
  75. package/cli/review-command-runner.mjs +68 -158
  76. package/cli/risk-calibration.mjs +8 -8
  77. package/cli/runner.mjs +20 -32
  78. package/cli/saved-lookup.mjs +2 -2
  79. package/cli/selected-input-worker.mjs +63 -0
  80. package/cli/sharing-review.mjs +5 -64
  81. package/cli/source-reliability.mjs +2 -2
  82. package/cli/support-arguments.mjs +49 -0
  83. package/cli/support-command-definitions.mjs +209 -0
  84. package/cli/support-command-runner.mjs +2 -2
  85. package/cli/workflow-arguments.mjs +86 -0
  86. package/cli/workflow-command-definitions.mjs +78 -0
  87. package/cli/workflow-command-runner.mjs +8 -1
  88. package/docs/cli-reference.md +27 -0
  89. package/docs/cli.md +223 -2
  90. package/docs/contextual-reviews.md +155 -0
  91. package/lib/analyst-taxonomy.mjs +2 -40
  92. package/lib/application-version.mjs +1 -1
  93. package/lib/availability.mjs +13 -789
  94. package/lib/bounded-contract-normalizers.mjs +4 -0
  95. package/lib/bounded-json.mjs +2 -330
  96. package/lib/browser-library-profile.mjs +62 -84
  97. package/lib/candidate-provenance-bounds.mjs +2 -0
  98. package/lib/classify.mjs +1 -1
  99. package/lib/ct-query.mjs +2 -27
  100. package/lib/ct-response-bounds.mjs +2 -9
  101. package/lib/ct-search.mjs +83 -47
  102. package/lib/dmarc-discovery.mjs +62 -0
  103. package/lib/dns-convergence-review.mjs +21 -8
  104. package/lib/dns-delegation-health.mjs +12 -8
  105. package/lib/dns-inheritance-review.mjs +53 -63
  106. package/lib/dns-intelligence.mjs +9 -7
  107. package/lib/dns-query-coverage.mjs +40 -0
  108. package/lib/domain-assurance.mjs +1 -1
  109. package/lib/domain-change-packet.mjs +12 -13
  110. package/lib/domain-change-review.mjs +32 -14
  111. package/lib/domain-control-flight-recorder.mjs +103 -27
  112. package/lib/domain-control-manifest.mjs +25 -17
  113. package/lib/domain-posture-analysis.mjs +22 -5
  114. package/lib/domain-posture.mjs +16 -15
  115. package/lib/domain-registration.mjs +392 -0
  116. package/lib/evidence-export-privacy.mjs +22 -15
  117. package/lib/evidence-export.mjs +3 -3
  118. package/lib/evidence-report.mjs +4 -1
  119. package/lib/external-intelligence-risk.mjs +2 -124
  120. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  121. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  122. package/lib/generated/retire-browser-catalog.mjs +332 -19
  123. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  124. package/lib/html-signals.mjs +7 -2
  125. package/lib/http-evidence-bounds.mjs +2 -8
  126. package/lib/http-intelligence.mjs +13 -1
  127. package/lib/idn-confusable-policy.mjs +6 -4
  128. package/lib/idn-confusables.mjs +1 -1
  129. package/lib/interchange-fidelity-registry.mjs +10 -0
  130. package/lib/lookup-contract-primitives.mjs +1 -1
  131. package/lib/lookup-diagnostics.mjs +1 -23
  132. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  133. package/lib/lookup-progress.mjs +50 -1
  134. package/lib/lookup-response-contract.mjs +69 -40
  135. package/lib/lookup-response.mjs +5 -15
  136. package/lib/lookup-source-progress.mjs +8 -22
  137. package/lib/lookup.mjs +1 -1
  138. package/lib/opportunity-scoring.mjs +2 -107
  139. package/lib/outbound-identity.mjs +1 -1
  140. package/lib/page-fingerprints.mjs +18 -1
  141. package/lib/perceptual-hash-comparison.mjs +2 -34
  142. package/lib/perceptual-hash.mjs +36 -10
  143. package/lib/portable-generator.mjs +2 -29
  144. package/lib/project-metadata.mjs +2 -8
  145. package/lib/rdap-client.mjs +11 -7
  146. package/lib/registrable-domain.mjs +2 -56
  147. package/lib/registrar-standing-contract.mjs +1 -1
  148. package/lib/registrar-standing.mjs +9 -13
  149. package/lib/registry-capabilities.mjs +2 -2
  150. package/lib/registry-capability-catalogue.mjs +27 -19
  151. package/lib/risk-calibration-summary.mjs +2 -352
  152. package/lib/risk-scoring.mjs +2 -345
  153. package/lib/scoring-evidence-quality.mjs +2 -107
  154. package/lib/semantic-version.mjs +2 -54
  155. package/lib/service-binding-dns.mjs +27 -0
  156. package/lib/smtp-transport-review.mjs +48 -19
  157. package/lib/sslbl-certificates.generated.mjs +141 -139
  158. package/lib/static-html-analysis.mjs +1 -1
  159. package/lib/structured-data-identity.mjs +1 -1
  160. package/lib/threat-intelligence-manifests.mjs +1 -1
  161. package/lib/threat-intelligence-runtime.mjs +1 -1
  162. package/lib/threat-intelligence-targets.mjs +1 -1
  163. package/lib/threat-intelligence-types.mjs +2 -75
  164. package/lib/typosquat-generator.mjs +11 -2
  165. package/lib/website-enrichment.mjs +388 -0
  166. package/lib/whois-authority.mjs +43 -5
  167. package/lib/whois-normalization.mjs +3 -1
  168. package/lib/whois-parser.mjs +5 -3
  169. package/package.json +13 -14
  170. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  171. package/packages/analysis/bounded-json.mjs +330 -0
  172. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  173. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  174. package/packages/analysis/ct-query.mjs +27 -0
  175. package/packages/analysis/ct-response-bounds.mjs +9 -0
  176. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  177. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  178. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  179. package/packages/analysis/opportunity-scoring.mjs +107 -0
  180. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  181. package/packages/analysis/portable-generator.mjs +29 -0
  182. package/packages/analysis/project-metadata.mjs +8 -0
  183. package/packages/analysis/registrable-domain.mjs +56 -0
  184. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  185. package/packages/analysis/risk-scoring.mjs +345 -0
  186. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  187. package/packages/analysis/semantic-version.mjs +54 -0
  188. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  189. package/packages/cases/case-evidence-links.mjs +135 -0
  190. package/packages/cases/case-evidence-model.mjs +78 -12
  191. package/packages/cases/case-evidence-quality.mjs +5 -0
  192. package/packages/cases/case-export-input.mjs +7 -5
  193. package/packages/cases/case-incident-context.mjs +52 -0
  194. package/packages/cases/case-markdown.mjs +11 -0
  195. package/packages/cases/case-migration-model.mjs +40 -24
  196. package/packages/cases/case-recheck-model.mjs +90 -32
  197. package/packages/cases/case-record-contracts.mjs +1 -1
  198. package/packages/cases/case-record-core.mjs +1 -1
  199. package/packages/cases/case-record-operations.mjs +40 -112
  200. package/packages/cases/case-record-projection.mjs +8 -1
  201. package/packages/cases/case-record-version-input.mjs +19 -0
  202. package/packages/cases/case-report.mjs +63 -27
  203. package/packages/cases/case-requested-evidence.mjs +146 -0
  204. package/packages/cases/case-response-actions.mjs +103 -1
  205. package/packages/cases/case-response-model.mjs +9 -1
  206. package/packages/cases/case-response-outcomes.mjs +29 -8
  207. package/packages/cases/case-response-packet-render.mjs +13 -9
  208. package/packages/cases/case-response-packet.mjs +25 -11
  209. package/packages/cases/case-response-records.mjs +0 -1
  210. package/packages/cases/case-response-review-inputs.mjs +14 -3
  211. package/packages/cases/case-storage-model.mjs +1 -1
  212. package/packages/cases/case-workflow-metadata.mjs +225 -0
  213. package/packages/cases/case-workflow-migration.mjs +43 -0
  214. package/packages/cases/http-summary.mjs +6 -1
  215. package/packages/comparison/favicon-similarity.mjs +1 -1
  216. package/packages/comparison/page-similarity.mjs +1 -1
  217. package/packages/comparison/relationship-evidence.mjs +21 -9
  218. package/packages/comparison/relationship-provenance.mjs +7 -1
  219. package/packages/contracts/analyst-interchange.mjs +28 -10
  220. package/packages/contracts/case-portability.mjs +118 -25
  221. package/packages/contracts/cli-command-semantics.mjs +2 -0
  222. package/packages/contracts/cli-grammar.mjs +6 -0
  223. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  224. package/packages/contracts/context-review.mjs +24 -0
  225. package/packages/contracts/document-intake.mjs +11 -0
  226. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  227. package/packages/contracts/domain-control-manifest.mjs +1 -1
  228. package/packages/contracts/domain-control-monitor.mjs +13 -2
  229. package/packages/contracts/domain-control-review.mjs +36 -15
  230. package/packages/contracts/har-review.mjs +2 -0
  231. package/packages/contracts/identity-events.mjs +4 -0
  232. package/packages/contracts/investigation-run.mjs +2 -0
  233. package/packages/contracts/ip-address.mjs +10 -0
  234. package/packages/contracts/mail-authentication.mjs +5 -0
  235. package/packages/contracts/message-intake.mjs +31 -0
  236. package/packages/contracts/offline-comparison.mjs +4 -1
  237. package/packages/contracts/passive-mail.mjs +10 -0
  238. package/packages/contracts/web-capture.mjs +2 -2
  239. package/packages/contracts/workspace-portability.mjs +63 -17
  240. package/packages/evidence/collection-quality.mjs +67 -0
  241. package/packages/evidence/dns-query-outcome.mjs +72 -0
  242. package/packages/evidence/email-recipient.mjs +18 -0
  243. package/packages/evidence/lookup-target.mjs +5 -1
  244. package/packages/evidence/observation.mjs +2 -0
  245. package/packages/evidence/png-pixels.mjs +117 -0
  246. package/packages/evidence/public-address-policy.mjs +83 -0
  247. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  248. package/packages/interchange/external-findings-import.mjs +4 -12
  249. package/packages/interchange/managed-indicator-export.mjs +35 -0
  250. package/packages/interchange/managed-indicator-set.mjs +210 -0
  251. package/packages/interchange/misp-indicator-export.mjs +93 -0
  252. package/packages/interchange/sharing-policy.mjs +56 -0
  253. package/packages/interchange/stix-indicator-export.mjs +129 -0
  254. package/packages/interchange/web-capture-import.mjs +22 -15
  255. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  256. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  257. package/packages/investigation/capture-coverage.mjs +57 -0
  258. package/packages/investigation/connector-provenance-review.mjs +100 -0
  259. package/packages/investigation/context-review.mjs +35 -0
  260. package/packages/investigation/document-intake.mjs +81 -0
  261. package/packages/investigation/docx-intake.mjs +133 -0
  262. package/packages/investigation/domain-history-review.mjs +100 -0
  263. package/packages/investigation/domain-transition-review.mjs +47 -0
  264. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  265. package/packages/investigation/har-intake.mjs +68 -0
  266. package/packages/investigation/identity-event-intake.mjs +131 -0
  267. package/packages/investigation/identity-incident-review.mjs +39 -0
  268. package/packages/investigation/incident-sequence-review.mjs +58 -0
  269. package/packages/investigation/intake-report.mjs +19 -0
  270. package/packages/investigation/investigation-manifest.mjs +2 -2
  271. package/packages/investigation/link-intake.mjs +179 -0
  272. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  273. package/packages/investigation/mail-authentication-review.mjs +222 -0
  274. package/packages/investigation/mail-header-identity.mjs +209 -0
  275. package/packages/investigation/message-intake.mjs +195 -0
  276. package/packages/investigation/page-behaviour.mjs +113 -0
  277. package/packages/investigation/pdf-intake.mjs +155 -0
  278. package/packages/investigation/platform-continuity-review.mjs +60 -0
  279. package/packages/investigation/qr-intake.mjs +56 -0
  280. package/packages/investigation/requested-action-hints.mjs +11 -0
  281. package/packages/investigation/selected-input-review.mjs +17 -0
  282. package/packages/investigation/storefront-review.mjs +70 -0
  283. package/packages/workspace/brand-profile-model.mjs +2 -2
  284. package/packages/workspace/bulk-review-model.mjs +28 -26
  285. package/packages/workspace/bulk-session-model.mjs +23 -13
  286. package/packages/workspace/campaign-model.mjs +1 -1
  287. package/packages/workspace/detection-rule-model.mjs +1 -1
  288. package/packages/workspace/hostile-input.mjs +1 -1
  289. package/packages/workspace/investigation-guide.mjs +2 -2
  290. package/packages/workspace/investigation-template-model.mjs +26 -2
  291. package/packages/workspace/page-baseline.mjs +2 -2
  292. package/packages/workspace/relationship-observation-model.mjs +17 -13
  293. package/packages/workspace/shortlist-model.mjs +14 -5
  294. package/packages/workspace/watchlist-history.mjs +28 -12
  295. package/packages/workspace/watchlist-store.mjs +17 -5
  296. package/packages/workspace/website-snapshot-model.mjs +28 -3
  297. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  298. package/packages/workspace/workspace-archive.mjs +5 -4
  299. package/third-party-notices.txt +549 -16
  300. package/lib/hostname.mjs +0 -2
@@ -8,14 +8,18 @@
8
8
  // contains raw RDAP/WHOIS material. This module deliberately excludes all raw
9
9
  // registry/web responses, contacts, cookies, screenshots, and authentication
10
10
  // data. Reports contain only the normalized case record.
11
- import { caseEvidenceTimeline, compareCaseEvidence, currentCaseEvidence } from "./case-model.mjs";
11
+ import { caseEvidenceTimeline, compareCaseEvidence, currentCaseEvidence, publishedCaseEvidenceTimelineForVerification } from "./case-evidence-model.mjs";
12
12
  import { httpSecurityHeaderLabel } from "./http-summary.mjs";
13
- import { analystInteroperabilityTags } from "../../lib/analyst-taxonomy.mjs";
14
- import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../../lib/portable-generator.mjs";
13
+ import { webCollectionScoreLimitation } from "../evidence/collection-quality.mjs";
14
+ import { analystInteroperabilityTags } from "../analysis/analyst-taxonomy.mjs";
15
+ import { caseEvidenceLinkIssues } from "./case-evidence-links.mjs";
16
+ import { caseTypeSummary } from "./case-workflow-metadata.mjs";
17
+ import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../analysis/portable-generator.mjs";
15
18
  import { buildCaseResponseLifecycleSummary, CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.mjs";
16
19
  import { normalizeCaseBrandProfileIds } from "./case-brand-profile-references.mjs";
17
20
  import { CASE_RECHECK_CONDITIONS } from "./case-recheck-model.mjs";
18
- import { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_REPORT_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
21
+ import { escapeCaseMarkdownInline as escapeMarkdownInline } from "./case-markdown.mjs";
22
+ import { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION, LATEST_PUBLIC_CASE_REPORT_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_REPORT_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
19
23
  // ---------------------------------------------------------------------------
20
24
  // Constants
21
25
  // ---------------------------------------------------------------------------
@@ -35,16 +39,6 @@ const LIMITATIONS_TEXT = [
35
39
  'Snapshot hostnames are excluded; supporting DNS, TLS and web observations need not concern the Case registration domain.',
36
40
  ...PUBLISHED_V2_3_LIMITATIONS.slice(2),
37
41
  ].join(' ');
38
- function escapeMarkdownInline(text) {
39
- return String(text)
40
- .replace(/[\r\n\u2028\u2029]+/g, ' ')
41
- .replace(/([\\`*_{}\[\]<>()#+!|~])/g, '\\$1')
42
- // GFM autolinks bare URLs independently of ordinary link syntax. Break
43
- // the scheme or the first `www.` separator without making normal domains,
44
- // dates, and prose noisy in the report source.
45
- .replace(/\b([a-z][a-z0-9+.-]{1,31}):(?=\/\/)/gi, '$1\\:')
46
- .replace(/\bwww\.(?=\S)/gi, 'www\\.');
47
- }
48
42
  /**
49
43
  * Escapes a multiline note body for safe inclusion in a Markdown blockquote.
50
44
  * Each line is individually escaped and prefixed with `> `.
@@ -90,6 +84,12 @@ function formatReportValue(value, field) {
90
84
  return String(value);
91
85
  return String(value);
92
86
  }
87
+ function readableRiskScore(snapshot) {
88
+ const value = escapeMarkdownInline(formatReportValue(snapshot.riskScore));
89
+ const limitation = snapshot.riskScore === null ? null
90
+ : webCollectionScoreLimitation(snapshot.webCollectionQuality, snapshot.scanDepth);
91
+ return limitation ? `${value} — ${escapeMarkdownInline(limitation)}` : value;
92
+ }
93
93
  /**
94
94
  * Returns a shallow clone of a snapshot containing only known schema fields.
95
95
  * Unknown/imported keys are never included.
@@ -111,6 +111,7 @@ function pickKnownSnapshotFields(snapshot) {
111
111
  capturedAt: snapshot.capturedAt,
112
112
  source: snapshot.source,
113
113
  scanDepth: snapshot.scanDepth,
114
+ ...(snapshot.webCollectionQuality ? { webCollectionQuality: { ...snapshot.webCollectionQuality } } : {}),
114
115
  availability: snapshot.availability,
115
116
  confidence: snapshot.confidence,
116
117
  riskModelVersion: snapshot.riskModelVersion,
@@ -173,14 +174,7 @@ export function buildCaseReport(caseRecord, options = {}) {
173
174
  const generator = buildPortableGeneratorMetadata(options.applicationVersion);
174
175
  // --- Build JSON report ---
175
176
  const timeline = caseEvidenceTimeline(caseRecord.evidenceHistory);
176
- const timelineEntries = timeline.map((entry) => ({
177
- snapshot: pickKnownSnapshotFields(entry.snapshot),
178
- isBaseline: entry.isBaseline,
179
- hasRepeatedObservation: entry.hasRepeatedObservation,
180
- changes: entry.changes?.map(({ field, label, before, after, tone }) => ({ field, label, before, after, tone })) ?? null,
181
- hasIncomparableChange: entry.hasIncomparableChange,
182
- incomparableReasons: [...entry.incomparableReasons],
183
- }));
177
+ const timelineEntries = projectReportTimeline(timeline);
184
178
  const selection = currentCaseEvidence(caseRecord);
185
179
  const latest = selection.snapshot;
186
180
  const currentAssessment = latest ? pickKnownSnapshotFields(latest) : null;
@@ -221,7 +215,9 @@ export function buildCaseReport(caseRecord, options = {}) {
221
215
  currentAssessment,
222
216
  evidenceTimeline: timelineEntries,
223
217
  analystResponse: {
218
+ ...(caseRecord.workflowMetadata === undefined ? {} : { workflowMetadata: structuredClone(caseRecord.workflowMetadata) }),
224
219
  evidencePins: caseRecord.evidencePins.map((item) => ({ ...item, limitations: [...item.limitations] })),
220
+ ...(caseRecord.evidenceLinks === undefined ? {} : { evidenceLinks: structuredClone(caseRecord.evidenceLinks) }),
225
221
  decisions: caseRecord.decisions.map((item) => ({ ...item, evidencePinIds: [...item.evidencePinIds] })),
226
222
  actions: caseRecord.actions.map((item) => ({
227
223
  ...item,
@@ -270,11 +266,25 @@ export function buildCaseReport(caseRecord, options = {}) {
270
266
  const md = buildMarkdown(json, options.includeAttribution !== false);
271
267
  return { json, markdown: md };
272
268
  }
269
+ function projectReportTimeline(timeline) {
270
+ return timeline.map(entry => ({
271
+ snapshot: pickKnownSnapshotFields(entry.snapshot), isBaseline: entry.isBaseline,
272
+ hasRepeatedObservation: entry.hasRepeatedObservation,
273
+ changes: entry.changes?.map(({ field, label, before, after, tone }) => ({ field, label, before, after, tone })) ?? null,
274
+ hasIncomparableChange: entry.hasIncomparableChange, incomparableReasons: [...entry.incomparableReasons],
275
+ }));
276
+ }
273
277
  /** Expected strict reader projection; historical disclosure wording is immutable. */
274
278
  export function buildCaseReportVerificationProjection(caseRecord, options, schemaVersion) {
275
- const current = buildCaseReport(caseRecord, options).json;
279
+ const generated = buildCaseReport(caseRecord, options).json;
276
280
  if (schemaVersion === CASE_REPORT_SCHEMA_VERSION)
277
- return current;
281
+ return generated;
282
+ if (caseRecord.evidenceHistory.some(snapshot => snapshot.webCollectionQuality !== undefined)) {
283
+ throw new TypeError('Published report formats cannot declare newer collection-quality fields.');
284
+ }
285
+ const current = { ...generated, evidenceTimeline: projectReportTimeline(publishedCaseEvidenceTimelineForVerification(caseRecord.evidenceHistory)) };
286
+ if (schemaVersion === LATEST_PUBLIC_CASE_REPORT_SCHEMA_VERSION)
287
+ return { ...current, schemaVersion };
278
288
  if (schemaVersion !== PUBLISHED_V2_3_CASE_REPORT_SCHEMA_VERSION) {
279
289
  throw new TypeError('No strict Case report projection is defined for this version.');
280
290
  }
@@ -325,6 +335,17 @@ function buildMarkdown(report, includeAttribution) {
325
335
  if (report.case.tags.length > 0) {
326
336
  lines.push(`| Tags | ${escapeMarkdownInline(report.case.tags.join(', '))} |`);
327
337
  }
338
+ const caseTypes = caseTypeSummary(report.analystResponse.workflowMetadata ? { workflowMetadata: report.analystResponse.workflowMetadata } : {});
339
+ if (caseTypes)
340
+ lines.push(`| Case types | ${escapeMarkdownInline(caseTypes)} |`);
341
+ const context = report.analystResponse.workflowMetadata?.investigationContext;
342
+ if (context) {
343
+ lines.push(`| Investigation objective | ${escapeMarkdownInline(context.objective)} |`);
344
+ lines.push(`| Incident URL (${context.urlRetention === 'exact' ? 'exact' : 'origin only'}) | ${escapeMarkdownInline(context.incidentUrl)} |`);
345
+ }
346
+ for (const target of report.analystResponse.workflowMetadata?.incidentTargets ?? []) {
347
+ lines.push(`| Incident link (${target.state}) | ${escapeMarkdownInline(target.url)} |`);
348
+ }
328
349
  lines.push(`| Notes included | ${report.case.notesIncluded ? 'Yes' : 'No'} |`);
329
350
  lines.push('');
330
351
  // Current assessment
@@ -333,7 +354,7 @@ function buildMarkdown(report, includeAttribution) {
333
354
  if (report.currentAssessment) {
334
355
  const a = report.currentAssessment;
335
356
  lines.push(`- **Availability:** ${escapeMarkdownInline(formatReportValue(a.availability))}`);
336
- lines.push(`- **Risk score:** ${escapeMarkdownInline(formatReportValue(a.riskScore))}`);
357
+ lines.push(`- **Risk score:** ${readableRiskScore(a)}`);
337
358
  lines.push(`- **Risk model:** ${a.riskModelVersion === null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(a.riskModelVersion))}`}`);
338
359
  lines.push(`- **Brand Profile context:** ${escapeMarkdownInline(formatReportValue(a.profileContextState))}`);
339
360
  if (a.profileContextLimitation)
@@ -388,7 +409,7 @@ function buildMarkdown(report, includeAttribution) {
388
409
  lines.push('**Key evidence:**');
389
410
  lines.push('');
390
411
  lines.push(`- Availability: ${escapeMarkdownInline(formatReportValue(snap.availability))}`);
391
- lines.push(`- Risk score: ${escapeMarkdownInline(formatReportValue(snap.riskScore))}`);
412
+ lines.push(`- Risk score: ${readableRiskScore(snap)}`);
392
413
  lines.push(`- Risk model: ${snap.riskModelVersion === null ? 'Unversioned' : `v${escapeMarkdownInline(formatReportValue(snap.riskModelVersion))}`}`);
393
414
  lines.push(`- Brand Profile context: ${escapeMarkdownInline(formatReportValue(snap.profileContextState))}`);
394
415
  if (snap.profileContextLimitation)
@@ -455,6 +476,8 @@ function buildMarkdown(report, includeAttribution) {
455
476
  lines.push('> Opportunity scores and factors use different or unversioned models, so their numeric difference is not treated as a domain change.');
456
477
  if (reasons.includes('scan-depth'))
457
478
  lines.push('> Capture depths differ, so unevaluated deep signals are not treated as additions or removals.');
479
+ if (reasons.includes('collection-quality'))
480
+ lines.push('> Missing or incomplete observations prevent comparison of affected registration, page, favicon or score fields.');
458
481
  if (reasons.length === 0 || reasons.includes('other'))
459
482
  lines.push('> The observations differ materially, but no reliable field-level comparison is available.');
460
483
  lines.push('');
@@ -464,7 +487,7 @@ function buildMarkdown(report, includeAttribution) {
464
487
  lines.push('## Analyst Decision Packet');
465
488
  lines.push('');
466
489
  const response = report.analystResponse;
467
- if (!response.evidencePins.length && !response.sightings.length && !response.decisions.length && !response.actions.length && !response.assertions.length && !response.manualTrail.length && !response.observedEffects.reviews.length && !response.closures.records.length && !response.branches.length) {
490
+ if (!response.evidencePins.length && !response.evidenceLinks?.length && !response.sightings.length && !response.decisions.length && !response.actions.length && !response.assertions.length && !response.manualTrail.length && !response.observedEffects.reviews.length && !response.closures.records.length && !response.branches.length) {
468
491
  lines.push('No evidence pins, source-qualified sightings, structured assertions, decision records, response actions, independent effect reviews, closures, or manual investigation steps recorded.');
469
492
  lines.push('');
470
493
  }
@@ -539,6 +562,12 @@ function buildMarkdown(report, includeAttribution) {
539
562
  }
540
563
  lines.push('');
541
564
  }
565
+ if (response.evidenceLinks?.length) {
566
+ lines.push('### Analyst-declared evidence relationships', '');
567
+ for (const { link, missingPinIds, cyclic } of caseEvidenceLinkIssues(response.evidenceLinks, response.evidencePins))
568
+ lines.push(`- ${escapeMarkdownInline(link.fromPinId)} ${link.kind === 'derived_from' ? 'derived from' : 'shares a source with'} ${escapeMarkdownInline(link.toPinId)} · recorded ${link.createdAt}`, ` Basis: ${escapeMarkdownInline(link.basis)}`, ...(link.withdrawal ? [` Withdrawn ${link.withdrawal.at}: ${escapeMarkdownInline(link.withdrawal.reason)}`] : []), ...(missingPinIds.length ? [` Referenced pins not retained: ${missingPinIds.map(escapeMarkdownInline).join(', ')}`] : []), ...(cyclic ? [' Conflicting imported derivation cycle; no order is inferred.'] : []));
569
+ lines.push('These relationships record analyst attribution, not independent corroboration or added confidence.', '');
570
+ }
542
571
  lines.push('### Decisions');
543
572
  lines.push('');
544
573
  if (!response.decisions.length)
@@ -564,6 +593,13 @@ function buildMarkdown(report, includeAttribution) {
564
593
  lines.push(` Route review due: ${escapeMarkdownInline(action.routeReviewAfter)}`);
565
594
  if (action.originActionId)
566
595
  lines.push(` Originating action: ${escapeMarkdownInline(action.originActionId)}`);
596
+ if (action.amendment)
597
+ lines.push(` Amendment of submitted packet SHA-256: ${action.amendment.packetDigestSha256}; request events: ${action.amendment.requestEventIds.map(escapeMarkdownInline).join(', ')}`);
598
+ for (const event of action.history)
599
+ if (event.evidenceRequest) {
600
+ const request = event.evidenceRequest;
601
+ lines.push(` Requested evidence [${request.state}]: ${escapeMarkdownInline(request.summary)}; deadline: ${request.dueAt ?? 'not stated'}; original packet: ${request.packetDigestSha256}${event.applied ? '' : ' (retained conflict)'}`, ` Prepared pins: ${request.evidencePinIds.map(escapeMarkdownInline).join(', ') || 'none'}${request.rationale ? `; ${escapeMarkdownInline(request.rationale)}` : ''}`);
602
+ }
567
603
  if (action.dueAt)
568
604
  lines.push(` Due: ${escapeMarkdownInline(action.dueAt)}`);
569
605
  if (action.followUpAt)
@@ -0,0 +1,146 @@
1
+ import { array, enumeration, exact, HEX_DIGEST_RE, iso, text } from "../evidence/artifact-structure.mjs";
2
+ import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
3
+ import { MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_VALUE_LENGTH } from "../contracts/case-portability.mjs";
4
+ import { SAFE_ID_RE } from "./case-response-values.mjs";
5
+ function identifier(value, label) {
6
+ const result = text(value, label, 64);
7
+ if (!SAFE_ID_RE.test(result))
8
+ throw new TypeError(`${label} is invalid.`);
9
+ return result;
10
+ }
11
+ function identifiers(value, label) {
12
+ const result = array(value, label, MAX_DECISION_PIN_REFERENCES).map(item => identifier(item, label));
13
+ if (new Set(result).size !== result.length)
14
+ throw new TypeError(`${label} contains duplicate identities.`);
15
+ return result;
16
+ }
17
+ function packetDigest(value) {
18
+ if (typeof value !== 'string' || !HEX_DIGEST_RE.test(value))
19
+ throw new TypeError('Select a recorded submitted-packet digest.');
20
+ return value;
21
+ }
22
+ export function readCaseEvidenceRequest(raw) {
23
+ if (raw === undefined)
24
+ return undefined;
25
+ assertWorkspaceInputGraph(raw, 'Requested evidence');
26
+ const row = exact(raw, ['id', 'packetDigestSha256', 'summary', 'dueAt', 'state', 'evidencePinIds', 'rationale', 'previousEventIds'], 'Requested evidence');
27
+ iso(row.dueAt, 'Requested evidence deadline', true);
28
+ const summary = text(row.summary, 'Requested evidence summary', MAX_RESPONSE_VALUE_LENGTH).trim();
29
+ const rationale = text(row.rationale, 'Requested evidence rationale', MAX_RESPONSE_RATIONALE_LENGTH, true).trim();
30
+ const state = enumeration(row.state, ['requested', 'prepared', 'unavailable'], 'Requested evidence state');
31
+ const evidencePinIds = identifiers(row.evidencePinIds, 'Requested evidence pins');
32
+ if (!summary || (state === 'prepared' && !evidencePinIds.length) || (state === 'unavailable' && !rationale)) {
33
+ throw new TypeError('Prepared evidence needs retained pins; unavailable evidence needs an explanation.');
34
+ }
35
+ return { id: identifier(row.id, 'Evidence request identity'), packetDigestSha256: packetDigest(row.packetDigestSha256),
36
+ summary, dueAt: row.dueAt, state, evidencePinIds, rationale,
37
+ previousEventIds: identifiers(row.previousEventIds, 'Prior evidence request events') };
38
+ }
39
+ export function readCasePacketAmendment(raw) {
40
+ if (raw === undefined)
41
+ return undefined;
42
+ assertWorkspaceInputGraph(raw, 'Packet amendment');
43
+ const row = exact(raw, ['packetDigestSha256', 'requestEventIds'], 'Packet amendment');
44
+ const requestEventIds = identifiers(row.requestEventIds, 'Amendment request events');
45
+ if (!requestEventIds.length)
46
+ throw new TypeError('Select at least one prepared evidence request.');
47
+ return { packetDigestSha256: packetDigest(row.packetDigestSha256), requestEventIds };
48
+ }
49
+ export function assertEvidenceRequestEvent(request, event) {
50
+ if (request && (event.nextState !== 'acknowledged' || !['submitted', 'acknowledged'].includes(String(event.previousState))
51
+ || (request.state === 'requested'
52
+ ? event.sourceClass !== 'provider' || event.providerOutcome !== 'more_information_requested' || request.evidencePinIds.length > 0 || Boolean(request.rationale) || request.previousEventIds.length > 0
53
+ : event.sourceClass !== 'analyst' || request.previousEventIds.length === 0)))
54
+ throw new TypeError('Requested evidence has incompatible event provenance.');
55
+ }
56
+ /** A digest belongs to the explicit delivery event, never the latest mutable reference. */
57
+ export function submittedPacketReceipts(action) {
58
+ return action.history.flatMap(event => {
59
+ const match = /^response-packet-sha256:([a-f0-9]{64})$/u.exec(event.reference ?? '');
60
+ return event.applied && event.previousState === 'authorised' && event.nextState === 'submitted'
61
+ && event.sourceClass === 'analyst' && match
62
+ ? [{ eventId: event.id, digestSha256: match[1], occurredAt: event.occurredAt }] : [];
63
+ });
64
+ }
65
+ export function latestEvidenceRequests(action) {
66
+ const events = action.history.filter((event) => event.applied && Boolean(event.evidenceRequest));
67
+ const replaced = new Set(events.flatMap(event => event.evidenceRequest.previousEventIds
68
+ .filter(id => events.some(parent => parent.id === id && parent.evidenceRequest.id === event.evidenceRequest.id))));
69
+ return events.filter(event => !replaced.has(event.id));
70
+ }
71
+ /** Causal links order same-time reviews and retain concurrent branches for explicit reconciliation. */
72
+ export function assertEvidenceRequestHistory(events, missingHistory) {
73
+ const byId = new Map(events.map(event => [event.id, event]));
74
+ const complete = new Set(), visiting = new Set();
75
+ const visit = (id) => {
76
+ if (complete.has(id))
77
+ return;
78
+ if (visiting.has(id))
79
+ throw new TypeError('Requested evidence history contains a cycle.');
80
+ visiting.add(id);
81
+ const request = byId.get(id)?.evidenceRequest;
82
+ for (const parentId of request?.previousEventIds ?? []) {
83
+ const parent = byId.get(parentId);
84
+ if (!parent && missingHistory)
85
+ continue;
86
+ if (!parent?.evidenceRequest || parent.evidenceRequest.id !== request.id
87
+ || parent.evidenceRequest.summary !== request.summary || parent.evidenceRequest.dueAt !== request.dueAt
88
+ || parent.evidenceRequest.packetDigestSha256 !== request.packetDigestSha256)
89
+ throw new TypeError('Requested evidence history does not preserve its original request.');
90
+ visit(parentId);
91
+ }
92
+ visiting.delete(id);
93
+ complete.add(id);
94
+ };
95
+ for (const event of events)
96
+ visit(event.id);
97
+ }
98
+ export function assertEvidenceRequestTransition(action, request, sourceClass, providerOutcome, validPinIds) {
99
+ if (!['submitted', 'acknowledged'].includes(action.state)
100
+ || !submittedPacketReceipts(action).some(receipt => receipt.digestSha256 === request.packetDigestSha256)) {
101
+ throw new Error('Requested evidence must refer to an explicitly recorded packet delivery on an open action.');
102
+ }
103
+ const previousEvents = latestEvidenceRequests(action).filter(event => event.evidenceRequest.id === request.id);
104
+ if (!previousEvents.length) {
105
+ if (request.state !== 'requested' || sourceClass !== 'provider' || providerOutcome !== 'more_information_requested'
106
+ || request.evidencePinIds.length || request.rationale || request.previousEventIds.length)
107
+ throw new Error('A new evidence request must record the provider request before preparation.');
108
+ }
109
+ else if (sourceClass !== 'analyst' || request.state === 'requested'
110
+ || previousEvents.some(({ evidenceRequest: previous }) => request.summary !== previous.summary
111
+ || request.dueAt !== previous.dueAt || request.packetDigestSha256 !== previous.packetDigestSha256)) {
112
+ throw new Error('Keep the original provider request unchanged; append a separate request for changed requirements.');
113
+ }
114
+ if (JSON.stringify(request.previousEventIds.slice().sort()) !== JSON.stringify(previousEvents.map(event => event.id).sort())) {
115
+ throw new Error('The evidence preparation changed. Review all current request events before saving.');
116
+ }
117
+ if (validPinIds && request.evidencePinIds.some(id => !validPinIds.has(id)))
118
+ throw new Error('Requested evidence refers to a pin no longer retained in this Case.');
119
+ }
120
+ export function assertPacketAmendment(actions, originActionId, amendment) {
121
+ const original = actions.find(action => action.id === originActionId);
122
+ if (!original || !submittedPacketReceipts(original).some(receipt => receipt.digestSha256 === amendment.packetDigestSha256)) {
123
+ throw new Error('An amendment requires the exact recorded packet delivery from its originating action.');
124
+ }
125
+ const latest = latestEvidenceRequests(original);
126
+ if (amendment.requestEventIds.some(id => !latest.some(event => event.id === id
127
+ && latest.filter(other => other.evidenceRequest.id === event.evidenceRequest.id).length === 1
128
+ && event.evidenceRequest.state === 'prepared' && event.evidenceRequest.packetDigestSha256 === amendment.packetDigestSha256))) {
129
+ throw new Error('Review the current prepared evidence requests before creating or submitting an amendment.');
130
+ }
131
+ }
132
+ export function assertPacketAmendmentSelection(actions, selectedActionId, selectedPinIds) {
133
+ const action = actions.find(item => item.id === selectedActionId);
134
+ if (!action?.amendment)
135
+ return;
136
+ assertPacketAmendment(actions, action.originActionId, action.amendment);
137
+ const original = actions.find(item => item.id === action.originActionId);
138
+ const required = latestEvidenceRequests(original).filter(event => action.amendment.requestEventIds.includes(event.id))
139
+ .flatMap(event => event.evidenceRequest.evidencePinIds);
140
+ if (required.some(id => !selectedPinIds.has(id)))
141
+ throw new TypeError('Select every prepared request pin for this amendment; the original packet is not regenerated.');
142
+ }
143
+ export function evidenceRequestDelivery(actions, actionId, eventId) {
144
+ return actions.flatMap(action => action.originActionId === actionId && action.amendment?.requestEventIds.includes(eventId)
145
+ ? submittedPacketReceipts(action).map(receipt => ({ actionId: action.id, ...receipt })) : []);
146
+ }
@@ -1,9 +1,76 @@
1
1
  // Append-only response actions: transitions, reconciliation and bounded history.
2
+ import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertEvidenceRequestTransition, assertPacketAmendment } from "./case-requested-evidence.mjs";
3
+ import { MAX_INTAKE_LINKS, MAX_MESSAGE_PARTS } from "../contracts/message-intake.mjs";
4
+ import { emailRecipient } from "../evidence/email-recipient.mjs";
5
+ import { responseRouteFreshness } from "./response-route-freshness.mjs";
2
6
  import { CASE_SCHEMA_VERSION, MAX_CASE_ACTIONS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, } from "../contracts/case-portability.mjs";
3
7
  import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
4
8
  import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_PROVIDER_OUTCOMES, } from "./case-response-records.mjs";
5
9
  import { SAFE_ID_RE, boundedCounter, compareCodeUnits, deterministicId, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
6
10
  const ACTION_TYPES = new Set(CASE_ACTION_TYPES);
11
+ /** Advisory page-memory comparison; neither authenticated identity nor a mutation. */
12
+ export function compareCaseIncomingNotice(action, report, input) {
13
+ const bounded = (value, maximum) => {
14
+ if (value === undefined)
15
+ return '';
16
+ if (value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value))
17
+ throw new TypeError('Notice comparison fields must be bounded single-line values.');
18
+ return value.trim();
19
+ };
20
+ const reference = bounded(input.reference, MAX_RESPONSE_REFERENCE_LENGTH);
21
+ const organisation = bounded(input.claimedOrganisation, MAX_RESPONSE_LABEL_LENGTH);
22
+ const recipient = emailRecipient(action?.recipient);
23
+ let recipientHostname = recipient ? recipient.slice(recipient.lastIndexOf('@') + 1).toLowerCase() : null;
24
+ if (!recipientHostname && action) {
25
+ try {
26
+ const url = new URL(action.recipient);
27
+ if (['https:', 'http:'].includes(url.protocol) && !url.username && !url.password)
28
+ recipientHostname = url.hostname;
29
+ }
30
+ catch { /* A free-text recipient does not establish a route hostname. */ }
31
+ }
32
+ const comparisons = [];
33
+ let senderDomains = 0, omittedSenderDomains = 0;
34
+ for (const identity of report.identities) {
35
+ if (identity.role !== 'from' && identity.role !== 'reply_to')
36
+ continue;
37
+ if (senderDomains >= MAX_MESSAGE_PARTS * 5) {
38
+ omittedSenderDomains += 1;
39
+ continue;
40
+ }
41
+ senderDomains += 1;
42
+ comparisons.push({
43
+ label: `Part ${identity.part} ${identity.role.replaceAll('_', ' ')} domain: ${identity.domain}`,
44
+ state: recipientHostname ? identity.domain.toLowerCase() === recipientHostname ? 'match' : 'mismatch' : 'unknown',
45
+ explanation: 'Compared only with the selected action recipient hostname. Domain agreement does not authenticate the sender; different domains may require independent clarification.',
46
+ });
47
+ }
48
+ if (!senderDomains)
49
+ comparisons.push({ label: 'Sender domains', state: 'unknown', explanation: 'No From or Reply-To domain was retained. Exact sender addresses are not retained.' });
50
+ if (omittedSenderDomains)
51
+ comparisons.push({ label: 'Additional sender context', state: 'unknown', explanation: `${omittedSenderDomains} additional From or Reply-To claims exceed the local comparison cap. No overall sender conclusion is available.` });
52
+ if (!report.links.length)
53
+ comparisons.push({ label: 'Supplied destinations', state: 'unknown', explanation: 'No supported destination was retained. Missing or partial extraction is not proof that no destination exists.' });
54
+ for (const link of report.links.slice(0, MAX_INTAKE_LINKS))
55
+ comparisons.push({
56
+ label: `Destination hostname: ${link.hostname}`,
57
+ state: recipientHostname ? link.hostname.toLowerCase() === recipientHostname ? 'match' : 'mismatch' : 'unknown',
58
+ explanation: 'Hostname context only, not an exact route match or authority check. Supplied destinations have not been opened.',
59
+ });
60
+ const deliveryReferences = action?.history.filter(event => event.applied && event.previousState === 'authorised'
61
+ && event.nextState === 'submitted' && event.sourceClass === 'analyst' && event.reference).map(event => event.reference) ?? [];
62
+ comparisons.push({ label: 'Delivery reference', state: !reference || !deliveryReferences.length ? 'unknown'
63
+ : deliveryReferences.includes(reference) ? 'match' : action.historyOmitted > 0 ? 'unknown' : 'mismatch',
64
+ explanation: 'Compared with retained, applied submission events only. Acknowledgements and later updates have separate references; missing submission history remains unknown. Matching text does not authenticate a notice.' });
65
+ comparisons.push({ label: 'Latest action reference', state: reference && action?.reference ? reference === action.reference ? 'match' : 'mismatch' : 'unknown',
66
+ explanation: 'Compared with the latest retained reference from any applied action event. This may be an acknowledgement or later update, not the delivery reference.' });
67
+ comparisons.push({ label: 'Claimed organisation', state: 'unknown', explanation: organisation ? 'The entered organisation is an analyst-supplied claim. Retained contact-source text is not a structured authenticated organisation identity.' : 'No organisation claim was supplied; no authenticated organisation identity is inferred.' });
68
+ return {
69
+ comparisons,
70
+ routeFreshness: action ? responseRouteFreshness(action.routeObservedAt, action.routeReviewAfter, input.now) : 'unknown',
71
+ confirmation: input.confirmedOutOfBand ? 'analyst_reported_out_of_band' : 'not_reported',
72
+ };
73
+ }
7
74
  const ACTION_STATES = new Set(CASE_ACTION_STATES);
8
75
  const PROVIDER_OUTCOMES = new Set(CASE_PROVIDER_OUTCOMES);
9
76
  const ACTION_EVENT_SOURCE_CLASSES = new Set(CASE_ACTION_EVENT_SOURCE_CLASSES);
@@ -29,6 +96,20 @@ const ACTION_TRANSITIONS = Object.freeze({
29
96
  acknowledged: new Set(['acknowledged', 'terminal']),
30
97
  terminal: new Set(),
31
98
  });
99
+ // Terminal also includes deliberate withdrawal before review. This is stage
100
+ // completion, not proof of authorisation or submission.
101
+ const RESPONSE_DECISION_COMPLETE = Object.freeze({
102
+ drafting: false,
103
+ ready_for_review: false,
104
+ reviewed: true,
105
+ authorised: true,
106
+ submitted: true,
107
+ acknowledged: true,
108
+ terminal: true,
109
+ });
110
+ export function caseActionCompletesResponseDecision(state) {
111
+ return RESPONSE_DECISION_COMPLETE[state];
112
+ }
32
113
  function bytes(value) {
33
114
  try {
34
115
  return new TextEncoder().encode(JSON.stringify(value)).byteLength;
@@ -76,6 +157,10 @@ function normalizeActionEvent(raw, actionId, options = {}) {
76
157
  && sourceClass !== 'analyst' && !migrationSnapshot)
77
158
  return null;
78
159
  const provenance = text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || `${sourceClass}_record`;
160
+ if (item.evidenceRequest !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
161
+ throw new Error('Requested evidence requires Case schema 17.');
162
+ const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
163
+ assertEvidenceRequestEvent(evidenceRequest, { previousState, nextState, sourceClass, providerOutcome: item.providerOutcome });
79
164
  const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
80
165
  ? item.providerOutcome
81
166
  : null;
@@ -104,6 +189,7 @@ function normalizeActionEvent(raw, actionId, options = {}) {
104
189
  : null;
105
190
  const eventMaterial = {
106
191
  previousState,
192
+ ...(evidenceRequest ? { evidenceRequest } : {}),
107
193
  nextState,
108
194
  occurredAt,
109
195
  sourceClass,
@@ -283,6 +369,10 @@ function normalizeAction(raw, fallback, options = {}) {
283
369
  const createdAt = iso(item.createdAt, fallback, options);
284
370
  const actionId = safeId(item.id, 'action', { recipient, createdAt });
285
371
  const history = normalizeActionHistory(item.history, item, actionId, createdAt, fallback, options);
372
+ assertEvidenceRequestHistory(history.history, history.omitted > 0);
373
+ if (item.amendment !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
374
+ throw new Error('Packet amendments require Case schema 17.');
375
+ const amendment = readCasePacketAmendment(item.amendment);
286
376
  const applied = history.history.filter((event) => event.applied);
287
377
  const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
288
378
  const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
@@ -297,6 +387,7 @@ function normalizeAction(raw, fallback, options = {}) {
297
387
  : null;
298
388
  return {
299
389
  id: actionId,
390
+ ...(amendment ? { amendment } : {}),
300
391
  type: legacyPlatformReview
301
392
  ? 'platform_report'
302
393
  : typeof item.type === 'string' && ACTION_TYPES.has(item.type)
@@ -481,6 +572,9 @@ export function appendCaseAction(current, raw, now) {
481
572
  : null;
482
573
  if (item.originActionId != null && !originActionId)
483
574
  throw new Error('A follow-on action requires an existing originating action.');
575
+ const amendment = readCasePacketAmendment(item.amendment);
576
+ if (amendment)
577
+ assertPacketAmendment(current, originActionId, amendment);
484
578
  const history = [{
485
579
  id: freshId('action-event'),
486
580
  previousState: null,
@@ -537,6 +631,12 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
537
631
  || !SAFE_ID_RE.test(item.evidencePinId) || (validPinIds && !validPinIds.has(item.evidencePinId)))) {
538
632
  throw new Error('An action transition evidence pin must reference a retained Case evidence pin.');
539
633
  }
634
+ const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
635
+ if (evidenceRequest)
636
+ assertEvidenceRequestTransition(action, evidenceRequest, sourceClass, item.providerOutcome, validPinIds);
637
+ if (action.amendment && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)) {
638
+ assertPacketAmendment(current, action.originActionId, action.amendment);
639
+ }
540
640
  const occurredAt = optionalIso(item.occurredAt) ?? now;
541
641
  const event = normalizeActionEvent({
542
642
  ...item,
@@ -558,7 +658,7 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
558
658
  return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
559
659
  }
560
660
  const ACTION_REVIEW_MATERIAL_FIELDS = [
561
- 'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId',
661
+ 'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId', 'amendment',
562
662
  ];
563
663
  export function updateCaseAction(current, raw, now, validPinIds) {
564
664
  const patch = record(raw);
@@ -587,6 +687,8 @@ export function updateCaseAction(current, raw, now, validPinIds) {
587
687
  throw new Error('An action requires a recipient or internal owner.');
588
688
  const materialChanged = ACTION_REVIEW_MATERIAL_FIELDS
589
689
  .some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
690
+ if (materialChanged && updated.amendment)
691
+ assertPacketAmendment(current, updated.originActionId, updated.amendment);
590
692
  if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
591
693
  throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
592
694
  }
@@ -5,7 +5,7 @@
5
5
  import { CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, } from "../contracts/case-portability.mjs";
6
6
  import { CASE_ASSERTION_EXTERNAL_ENTITY_TYPES, CASE_ASSERTION_EXTERNAL_FORMATS, CASE_ASSERTION_KINDS, CASE_ASSERTION_STATES, CASE_DECISION_CONFIDENCE_LEVELS, CASE_EVIDENCE_RELATION_STANCES, CASE_MANUAL_TRAIL_KINDS, CASE_SIGHTING_CATEGORIES, CASE_SIGHTING_STATES, CASE_TRANSITION_EXPECTATIONS, } from "./case-response-records.mjs";
7
7
  import { COMPLETENESS, SAFE_ID_RE, compareCodeUnits, freshId, iso, limitations, optionalIso, record, safeId, text, uniqueIds, } from "./case-response-values.mjs";
8
- import { isValidAsciiHostname } from "../../lib/hostname.mjs";
8
+ import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
9
9
  import { readCaseRecheckContext } from "./case-recheck-model.mjs";
10
10
  export * from "./case-response-records.mjs";
11
11
  export * from "./case-response-actions.mjs";
@@ -195,6 +195,8 @@ export function appendCaseEvidencePin(current, raw, now) {
195
195
  export function appendCaseEvidencePins(current, raw, now) {
196
196
  if (!Array.isArray(raw) || !raw.length)
197
197
  throw new Error('An evidence checkpoint requires at least one selected fact.');
198
+ if (raw.length > MAX_CASE_CHECKPOINT_FACTS)
199
+ throw new Error(`A checkpoint can retain at most ${MAX_CASE_CHECKPOINT_FACTS} selected facts. No evidence was saved.`);
198
200
  let output = [...current];
199
201
  let added = 0;
200
202
  for (const item of raw.slice(0, MAX_CASE_CHECKPOINT_FACTS)) {
@@ -240,6 +242,12 @@ export function normalizeCaseDecisions(raw, fallback, validPinIds, options = {})
240
242
  .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))
241
243
  .slice(-MAX_CASE_DECISIONS);
242
244
  }
245
+ /** Counts decisions, not links; references to pins no longer retained do not
246
+ * support a decision. Inputs have already passed their owning normalisers. */
247
+ export function countEvidenceLinkedCaseDecisions(decisions, pins) {
248
+ const retainedPinIds = new Set(pins.map(pin => pin.id));
249
+ return decisions.filter(decision => decision.evidencePinIds.some(id => retainedPinIds.has(id))).length;
250
+ }
243
251
  export function appendCaseDecision(current, raw, now, validPinIds) {
244
252
  if (current.length >= MAX_CASE_DECISIONS) {
245
253
  throw new Error(`A Case can retain at most ${MAX_CASE_DECISIONS} decisions. No existing decision was removed.`);
@@ -2,6 +2,7 @@
2
2
  import { MAX_CASE_CLOSURES, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, } from "../contracts/case-portability.mjs";
3
3
  import { latestObservationCohort, } from "../evidence/latest-observations.mjs";
4
4
  import { readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
5
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
5
6
  import { CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, } from "./case-response-records.mjs";
6
7
  import { COMPLETENESS, SAFE_ID_RE, boundedCounter, compareCodeUnits, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
7
8
  const OBSERVED_EFFECT_STATES = new Set(CASE_OBSERVED_EFFECT_STATES);
@@ -222,6 +223,31 @@ export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, valid
222
223
  ]),
223
224
  };
224
225
  }
226
+ /** New-write policy only. Historical normalisation must not re-adjudicate a closure. */
227
+ export function caseClosureReviewBlocker(reason, review, now) {
228
+ if (reason !== 'independently_not_reproduced' && reason !== 'infrastructure_changed')
229
+ return null;
230
+ const expected = reason === 'independently_not_reproduced' ? 'not_reproduced' : 'changed';
231
+ const invalidLink = reason === 'independently_not_reproduced'
232
+ ? 'This closure reason requires a linked independent not-reproduced review.'
233
+ : 'This closure reason requires a linked independent changed review.';
234
+ if (!review || review.state !== expected)
235
+ return invalidLink;
236
+ const observedAt = normalizeExplicitIsoTimestamp(review.observedAt);
237
+ const createdAt = normalizeExplicitIsoTimestamp(review.createdAt);
238
+ const closedAt = normalizeExplicitIsoTimestamp(now);
239
+ if (!observedAt || !createdAt || !closedAt
240
+ || Date.parse(observedAt) > Date.parse(closedAt)
241
+ || Date.parse(createdAt) > Date.parse(closedAt))
242
+ return invalidLink;
243
+ if (reason === 'independently_not_reproduced') {
244
+ if (review.completeness !== 'complete')
245
+ return 'Independent non-reproduction closure requires a complete observation. Keep this limited review and collect or record a complete recheck.';
246
+ if (review.recheck && review.recheck.conditionsMatch !== 'comparable')
247
+ return 'Independent non-reproduction closure requires comparable recheck conditions.';
248
+ }
249
+ return null;
250
+ }
225
251
  export function appendCaseClosure(current, raw, now, observedEffects, actions) {
226
252
  const item = record(raw);
227
253
  const reason = typeof item.reason === 'string' && CLOSURE_REASONS.has(item.reason)
@@ -233,14 +259,9 @@ export function appendCaseClosure(current, raw, now, observedEffects, actions) {
233
259
  const action = typeof item.actionId === 'string'
234
260
  ? actions.find((candidate) => candidate.id === item.actionId) ?? null
235
261
  : null;
236
- if (reason === 'independently_not_reproduced'
237
- && (review?.state !== 'not_reproduced' || Date.parse(review.observedAt) > Date.parse(now) || Date.parse(review.createdAt) > Date.parse(now))) {
238
- throw new Error('This closure reason requires a linked independent not-reproduced review.');
239
- }
240
- if (reason === 'infrastructure_changed'
241
- && (review?.state !== 'changed' || Date.parse(review.observedAt) > Date.parse(now) || Date.parse(review.createdAt) > Date.parse(now))) {
242
- throw new Error('This closure reason requires a linked independent changed review.');
243
- }
262
+ const reviewBlocker = caseClosureReviewBlocker(reason, review, now);
263
+ if (reviewBlocker)
264
+ throw new Error(reviewBlocker);
244
265
  if (reason === 'provider_reported_resolution_not_independently_checked'
245
266
  && (action?.providerOutcome !== 'provider_reports_resolved'
246
267
  || !action.history.some((event) => event.applied