@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
@@ -1,9 +1,78 @@
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 { readCaseDeliveryPacketReceipt, readCasePacketCorrection, assertDeliveryPacketReceipt, correctionDelivery } from "./case-packet-correction.mjs";
4
+ import { MAX_INTAKE_LINKS, MAX_MESSAGE_PARTS } from "../contracts/message-intake.mjs";
5
+ import { emailRecipient } from "../evidence/email-recipient.mjs";
6
+ import { responseRouteFreshness } from "./response-route-freshness.mjs";
7
+ import { readCaseResponseObjects, readCaseResponseObjectOutcome, assertCaseObjectOutcome, sameCaseResponseObject } from "./case-response-object.mjs";
2
8
  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
9
  import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
4
10
  import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_PROVIDER_OUTCOMES, } from "./case-response-records.mjs";
5
11
  import { SAFE_ID_RE, boundedCounter, compareCodeUnits, deterministicId, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
6
12
  const ACTION_TYPES = new Set(CASE_ACTION_TYPES);
13
+ /** Advisory page-memory comparison; neither authenticated identity nor a mutation. */
14
+ export function compareCaseIncomingNotice(action, report, input) {
15
+ const bounded = (value, maximum) => {
16
+ if (value === undefined)
17
+ return '';
18
+ if (value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value))
19
+ throw new TypeError('Notice comparison fields must be bounded single-line values.');
20
+ return value.trim();
21
+ };
22
+ const reference = bounded(input.reference, MAX_RESPONSE_REFERENCE_LENGTH);
23
+ const organisation = bounded(input.claimedOrganisation, MAX_RESPONSE_LABEL_LENGTH);
24
+ const recipient = emailRecipient(action?.recipient);
25
+ let recipientHostname = recipient ? recipient.slice(recipient.lastIndexOf('@') + 1).toLowerCase() : null;
26
+ if (!recipientHostname && action) {
27
+ try {
28
+ const url = new URL(action.recipient);
29
+ if (['https:', 'http:'].includes(url.protocol) && !url.username && !url.password)
30
+ recipientHostname = url.hostname;
31
+ }
32
+ catch { /* A free-text recipient does not establish a route hostname. */ }
33
+ }
34
+ const comparisons = [];
35
+ let senderDomains = 0, omittedSenderDomains = 0;
36
+ for (const identity of report.identities) {
37
+ if (identity.role !== 'from' && identity.role !== 'reply_to')
38
+ continue;
39
+ if (senderDomains >= MAX_MESSAGE_PARTS * 5) {
40
+ omittedSenderDomains += 1;
41
+ continue;
42
+ }
43
+ senderDomains += 1;
44
+ comparisons.push({
45
+ label: `Part ${identity.part} ${identity.role.replaceAll('_', ' ')} domain: ${identity.domain}`,
46
+ state: recipientHostname ? identity.domain.toLowerCase() === recipientHostname ? 'match' : 'mismatch' : 'unknown',
47
+ explanation: 'Compared only with the selected action recipient hostname. Domain agreement does not authenticate the sender; different domains may require independent clarification.',
48
+ });
49
+ }
50
+ if (!senderDomains)
51
+ comparisons.push({ label: 'Sender domains', state: 'unknown', explanation: 'No From or Reply-To domain was retained. Exact sender addresses are not retained.' });
52
+ if (omittedSenderDomains)
53
+ 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.` });
54
+ if (!report.links.length)
55
+ comparisons.push({ label: 'Supplied destinations', state: 'unknown', explanation: 'No supported destination was retained. Missing or partial extraction is not proof that no destination exists.' });
56
+ for (const link of report.links.slice(0, MAX_INTAKE_LINKS))
57
+ comparisons.push({
58
+ label: `Destination hostname: ${link.hostname}`,
59
+ state: recipientHostname ? link.hostname.toLowerCase() === recipientHostname ? 'match' : 'mismatch' : 'unknown',
60
+ explanation: 'Hostname context only, not an exact route match or authority check. Supplied destinations have not been opened.',
61
+ });
62
+ const deliveryReferences = action?.history.filter(event => event.applied && event.previousState === 'authorised'
63
+ && event.nextState === 'submitted' && event.sourceClass === 'analyst' && event.reference).map(event => event.reference) ?? [];
64
+ comparisons.push({ label: 'Delivery reference', state: !reference || !deliveryReferences.length ? 'unknown'
65
+ : deliveryReferences.includes(reference) ? 'match' : action.historyOmitted > 0 ? 'unknown' : 'mismatch',
66
+ 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.' });
67
+ comparisons.push({ label: 'Latest action reference', state: reference && action?.reference ? reference === action.reference ? 'match' : 'mismatch' : 'unknown',
68
+ explanation: 'Compared with the latest retained reference from any applied action event. This may be an acknowledgement or later update, not the delivery reference.' });
69
+ 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.' });
70
+ return {
71
+ comparisons,
72
+ routeFreshness: action ? responseRouteFreshness(action.routeObservedAt, action.routeReviewAfter, input.now) : 'unknown',
73
+ confirmation: input.confirmedOutOfBand ? 'analyst_reported_out_of_band' : 'not_reported',
74
+ };
75
+ }
7
76
  const ACTION_STATES = new Set(CASE_ACTION_STATES);
8
77
  const PROVIDER_OUTCOMES = new Set(CASE_PROVIDER_OUTCOMES);
9
78
  const ACTION_EVENT_SOURCE_CLASSES = new Set(CASE_ACTION_EVENT_SOURCE_CLASSES);
@@ -29,6 +98,20 @@ const ACTION_TRANSITIONS = Object.freeze({
29
98
  acknowledged: new Set(['acknowledged', 'terminal']),
30
99
  terminal: new Set(),
31
100
  });
101
+ // Terminal also includes deliberate withdrawal before review. This is stage
102
+ // completion, not proof of authorisation or submission.
103
+ const RESPONSE_DECISION_COMPLETE = Object.freeze({
104
+ drafting: false,
105
+ ready_for_review: false,
106
+ reviewed: true,
107
+ authorised: true,
108
+ submitted: true,
109
+ acknowledged: true,
110
+ terminal: true,
111
+ });
112
+ export function caseActionCompletesResponseDecision(state) {
113
+ return RESPONSE_DECISION_COMPLETE[state];
114
+ }
32
115
  function bytes(value) {
33
116
  try {
34
117
  return new TextEncoder().encode(JSON.stringify(value)).byteLength;
@@ -76,6 +159,16 @@ function normalizeActionEvent(raw, actionId, options = {}) {
76
159
  && sourceClass !== 'analyst' && !migrationSnapshot)
77
160
  return null;
78
161
  const provenance = text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || `${sourceClass}_record`;
162
+ if (item.evidenceRequest !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
163
+ throw new Error('Requested evidence requires Case schema 17.');
164
+ const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
165
+ if (item.packetReceipt !== undefined && options.sourceVersion != null && options.sourceVersion < 19)
166
+ throw new TypeError('Delivery receipts require Case schema 19.');
167
+ const packetReceipt = readCaseDeliveryPacketReceipt(item.packetReceipt);
168
+ assertDeliveryPacketReceipt(packetReceipt, { previousState, nextState, sourceClass, occurredAt,
169
+ reference: typeof item.reference === 'string' ? item.reference : null,
170
+ responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }, actionId);
171
+ assertEvidenceRequestEvent(evidenceRequest, { previousState, nextState, sourceClass, providerOutcome: item.providerOutcome });
79
172
  const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
80
173
  ? item.providerOutcome
81
174
  : null;
@@ -104,6 +197,10 @@ function normalizeActionEvent(raw, actionId, options = {}) {
104
197
  : null;
105
198
  const eventMaterial = {
106
199
  previousState,
200
+ ...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }),
201
+ ...(item.objectOutcome === undefined ? {} : { objectOutcome: readCaseResponseObjectOutcome(item.objectOutcome, options.sourceVersion) }),
202
+ ...(evidenceRequest ? { evidenceRequest } : {}),
203
+ ...(packetReceipt ? { packetReceipt } : {}),
107
204
  nextState,
108
205
  occurredAt,
109
206
  sourceClass,
@@ -120,6 +217,16 @@ function normalizeActionEvent(raw, actionId, options = {}) {
120
217
  ? item.originActionId
121
218
  : null,
122
219
  };
220
+ if (eventMaterial.objectOutcome) {
221
+ if (!['submitted', 'acknowledged', 'terminal'].includes(nextState) || previousState === 'authorised')
222
+ throw new TypeError('Object outcomes require a separate attributed event after submission.');
223
+ if (sourceClass !== 'provider' && sourceClass !== 'analyst')
224
+ throw new TypeError('Object outcomes require an attributed provider or analyst event.');
225
+ if (!eventMaterial.responseObjects?.length)
226
+ throw new TypeError('Select the objects concerned by this provider outcome; no complete-action resolution is inferred.');
227
+ for (const object of eventMaterial.responseObjects)
228
+ assertCaseObjectOutcome(eventMaterial.objectOutcome, object);
229
+ }
123
230
  return {
124
231
  id: safeId(item.id, 'action-event', { actionId, ...eventMaterial }),
125
232
  ...eventMaterial,
@@ -283,6 +390,15 @@ function normalizeAction(raw, fallback, options = {}) {
283
390
  const createdAt = iso(item.createdAt, fallback, options);
284
391
  const actionId = safeId(item.id, 'action', { recipient, createdAt });
285
392
  const history = normalizeActionHistory(item.history, item, actionId, createdAt, fallback, options);
393
+ assertEvidenceRequestHistory(history.history, history.omitted > 0);
394
+ if (item.amendment !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
395
+ throw new Error('Packet amendments require Case schema 17.');
396
+ const amendment = readCasePacketAmendment(item.amendment);
397
+ if (item.correction !== undefined && options.sourceVersion != null && options.sourceVersion < 19)
398
+ throw new TypeError('Packet corrections require Case schema 19.');
399
+ const correction = readCasePacketCorrection(item.correction);
400
+ if (correction && amendment)
401
+ throw new TypeError('Self-initiated correction and provider-request amendment are separate purposes.');
286
402
  const applied = history.history.filter((event) => event.applied);
287
403
  const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
288
404
  const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
@@ -297,6 +413,9 @@ function normalizeAction(raw, fallback, options = {}) {
297
413
  : null;
298
414
  return {
299
415
  id: actionId,
416
+ ...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }),
417
+ ...(amendment ? { amendment } : {}),
418
+ ...(correction ? { correction } : {}),
300
419
  type: legacyPlatformReview
301
420
  ? 'platform_report'
302
421
  : typeof item.type === 'string' && ACTION_TYPES.has(item.type)
@@ -481,9 +600,17 @@ export function appendCaseAction(current, raw, now) {
481
600
  : null;
482
601
  if (item.originActionId != null && !originActionId)
483
602
  throw new Error('A follow-on action requires an existing originating action.');
603
+ const amendment = readCasePacketAmendment(item.amendment);
604
+ if (amendment)
605
+ assertPacketAmendment(current, originActionId, amendment);
606
+ const correction = readCasePacketCorrection(item.correction);
607
+ if (correction)
608
+ correctionDelivery(current, { originActionId, correction,
609
+ recipient: text(item.recipient, MAX_RESPONSE_RECIPIENT_LENGTH), responseObjects: readCaseResponseObjects(item.responseObjects) });
484
610
  const history = [{
485
611
  id: freshId('action-event'),
486
612
  previousState: null,
613
+ ...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects) }),
487
614
  nextState: 'drafting',
488
615
  occurredAt: now,
489
616
  sourceClass: 'analyst',
@@ -508,11 +635,22 @@ export function appendCaseAction(current, raw, now) {
508
635
  throw new Error('An action requires a recipient or internal owner.');
509
636
  return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
510
637
  }
638
+ export function providerOutcomeRequiresObjectScope(outcome) {
639
+ return outcome === 'provider_reports_resolved' || outcome === 'partially_remediated';
640
+ }
511
641
  export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
512
642
  const action = current.find((item) => item.id === actionId);
513
643
  if (!action)
514
644
  throw new Error('That case action no longer exists.');
515
645
  const item = record(raw);
646
+ const selectedObjects = readCaseResponseObjects(item.responseObjects);
647
+ const outcome = readCaseResponseObjectOutcome(item.objectOutcome);
648
+ if (selectedObjects?.some(object => !action.responseObjects?.some(bound => sameCaseResponseObject(bound, object))))
649
+ throw new TypeError('A transition may concern only objects explicitly bound to its action.');
650
+ if (outcome && !selectedObjects?.length)
651
+ throw new TypeError('Explicitly select the affected objects for this outcome; it is not applied to every action object.');
652
+ if (action.responseObjects?.length && providerOutcomeRequiresObjectScope(item.providerOutcome) && !selectedObjects?.length)
653
+ throw new TypeError('Explicitly select the objects covered by this provider-reported outcome; other action objects remain unchecked.');
516
654
  const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
517
655
  ? item.nextState
518
656
  : null;
@@ -537,9 +675,24 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
537
675
  || !SAFE_ID_RE.test(item.evidencePinId) || (validPinIds && !validPinIds.has(item.evidencePinId)))) {
538
676
  throw new Error('An action transition evidence pin must reference a retained Case evidence pin.');
539
677
  }
678
+ const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
679
+ if (evidenceRequest)
680
+ assertEvidenceRequestTransition(action, evidenceRequest, sourceClass, item.providerOutcome, validPinIds);
681
+ if (action.amendment && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)) {
682
+ assertPacketAmendment(current, action.originActionId, action.amendment);
683
+ }
684
+ if (action.correction && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState))
685
+ correctionDelivery(current, action);
686
+ const packetReceipt = readCaseDeliveryPacketReceipt(item.packetReceipt);
687
+ if (packetReceipt && (packetReceipt.recipient !== action.recipient
688
+ || JSON.stringify(packetReceipt.responseObjects) !== JSON.stringify(action.responseObjects ?? [])))
689
+ throw new TypeError('The delivered packet receipt no longer matches this action recipient or scope.');
690
+ if (action.correction && nextState === 'submitted' && !packetReceipt)
691
+ throw new TypeError('Record the separately prepared correction packet receipt only after actual delivery.');
540
692
  const occurredAt = optionalIso(item.occurredAt) ?? now;
541
693
  const event = normalizeActionEvent({
542
694
  ...item,
695
+ ...(selectedObjects !== undefined ? { responseObjects: selectedObjects } : action.responseObjects === undefined ? {} : { responseObjects: action.responseObjects }),
543
696
  id: freshId('action-event'),
544
697
  previousState: action.state,
545
698
  nextState,
@@ -558,7 +711,7 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
558
711
  return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
559
712
  }
560
713
  const ACTION_REVIEW_MATERIAL_FIELDS = [
561
- 'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId',
714
+ 'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId', 'amendment', 'correction', 'responseObjects',
562
715
  ];
563
716
  export function updateCaseAction(current, raw, now, validPinIds) {
564
717
  const patch = record(raw);
@@ -587,6 +740,10 @@ export function updateCaseAction(current, raw, now, validPinIds) {
587
740
  throw new Error('An action requires a recipient or internal owner.');
588
741
  const materialChanged = ACTION_REVIEW_MATERIAL_FIELDS
589
742
  .some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
743
+ if (materialChanged && updated.amendment)
744
+ assertPacketAmendment(current, updated.originActionId, updated.amendment);
745
+ if (materialChanged && updated.correction)
746
+ correctionDelivery(current, updated);
590
747
  if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
591
748
  throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
592
749
  }
@@ -600,6 +757,7 @@ export function updateCaseAction(current, raw, now, validPinIds) {
600
757
  occurredAt: now,
601
758
  sourceClass: 'browser_local',
602
759
  provenance: 'material_action_change',
760
+ ...(updated.responseObjects === undefined ? {} : { responseObjects: updated.responseObjects }),
603
761
  reference: null,
604
762
  evidencePinId: null,
605
763
  limitations: ['Material action inputs changed after review; prior readiness, review, or authorisation no longer applies.'],
@@ -5,11 +5,14 @@
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
+ import { readCaseResponseObject } from "./case-response-object.mjs";
11
+ import { normalizeInfrastructureObservation } from "../investigation/infrastructure-observation.mjs";
10
12
  export * from "./case-response-records.mjs";
11
13
  export * from "./case-response-actions.mjs";
12
14
  export * from "./case-response-outcomes.mjs";
15
+ export * from "./case-response-object.mjs";
13
16
  export { MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_CLOSURES, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, } from "../contracts/case-portability.mjs";
14
17
  const TRANSITION_EXPECTATIONS = new Set(CASE_TRANSITION_EXPECTATIONS);
15
18
  const DECISION_CONFIDENCE_LEVELS = new Set(CASE_DECISION_CONFIDENCE_LEVELS);
@@ -108,6 +111,8 @@ function normalizeEvidenceRelations(value, legacyIds, validIds) {
108
111
  }
109
112
  function normalizePin(raw, fallback, options = {}) {
110
113
  const item = record(raw);
114
+ if (item.infrastructureObservation !== undefined && options.sourceVersion != null && options.sourceVersion < 18)
115
+ throw new TypeError('Infrastructure observations require Case schema 18 or later.');
111
116
  const label = text(item.label, MAX_RESPONSE_LABEL_LENGTH);
112
117
  const value = text(item.value, MAX_RESPONSE_VALUE_LENGTH);
113
118
  if (!label || !value)
@@ -141,6 +146,8 @@ function normalizePin(raw, fallback, options = {}) {
141
146
  : null,
142
147
  limitations: limitations(item.limitations),
143
148
  createdAt,
149
+ ...(item.responseObject === undefined ? {} : { responseObject: readCaseResponseObject(item.responseObject, options.sourceVersion) }),
150
+ ...(item.infrastructureObservation === undefined ? {} : { infrastructureObservation: normalizeInfrastructureObservation(item.infrastructureObservation) }),
144
151
  };
145
152
  normalized.certificateObservation = options.allowCertificateObservation === false
146
153
  ? null
@@ -195,6 +202,8 @@ export function appendCaseEvidencePin(current, raw, now) {
195
202
  export function appendCaseEvidencePins(current, raw, now) {
196
203
  if (!Array.isArray(raw) || !raw.length)
197
204
  throw new Error('An evidence checkpoint requires at least one selected fact.');
205
+ if (raw.length > MAX_CASE_CHECKPOINT_FACTS)
206
+ throw new Error(`A checkpoint can retain at most ${MAX_CASE_CHECKPOINT_FACTS} selected facts. No evidence was saved.`);
198
207
  let output = [...current];
199
208
  let added = 0;
200
209
  for (const item of raw.slice(0, MAX_CASE_CHECKPOINT_FACTS)) {
@@ -240,6 +249,12 @@ export function normalizeCaseDecisions(raw, fallback, validPinIds, options = {})
240
249
  .sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))
241
250
  .slice(-MAX_CASE_DECISIONS);
242
251
  }
252
+ /** Counts decisions, not links; references to pins no longer retained do not
253
+ * support a decision. Inputs have already passed their owning normalisers. */
254
+ export function countEvidenceLinkedCaseDecisions(decisions, pins) {
255
+ const retainedPinIds = new Set(pins.map(pin => pin.id));
256
+ return decisions.filter(decision => decision.evidencePinIds.some(id => retainedPinIds.has(id))).length;
257
+ }
243
258
  export function appendCaseDecision(current, raw, now, validPinIds) {
244
259
  if (current.length >= MAX_CASE_DECISIONS) {
245
260
  throw new Error(`A Case can retain at most ${MAX_CASE_DECISIONS} decisions. No existing decision was removed.`);
@@ -0,0 +1,119 @@
1
+ // Explicit object scope, not ownership, collection or proof of remediation.
2
+ import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
3
+ import { OBJECT_RESPONSE_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
4
+ import { array, enumeration, exact, text } from "../evidence/artifact-structure.mjs";
5
+ export const CASE_RESPONSE_OBJECT_KINDS = ['domain', 'hostname', 'page', 'redirect', 'advertisement', 'social_account', 'social_post', 'messaging', 'storefront', 'app', 'other'];
6
+ export const CASE_RESPONSE_OBJECT_OUTCOMES = ['removed', 'restricted', 'suspended', 'delisted', 'warning', 'transferred', 'restored', 'disputed'];
7
+ export const MAX_CASE_RESPONSE_OBJECT_IDENTIFIER = 1_979;
8
+ export const MAX_CASE_RESPONSE_OBJECTS = 20;
9
+ export function readCaseResponseObjects(value, sourceVersion) {
10
+ if (value === undefined)
11
+ return undefined;
12
+ if (sourceVersion != null && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
13
+ throw new TypeError('Object-specific response scope requires Case schema 18 or later.');
14
+ const objects = array(value, 'Response objects', MAX_CASE_RESPONSE_OBJECTS).map(item => readCaseResponseObject(item, sourceVersion));
15
+ const keyed = objects.map(object => [JSON.stringify(object), object]);
16
+ if (new Set(keyed.map(([key]) => key)).size !== keyed.length)
17
+ throw new TypeError('Response objects must be unique.');
18
+ return Object.freeze(keyed.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([, object]) => object));
19
+ }
20
+ /** Historical absence stays absent; a declared historical format cannot carry new fields. */
21
+ export function readCaseResponseObject(value, sourceVersion) {
22
+ if (value === undefined)
23
+ return undefined;
24
+ if (sourceVersion != null && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
25
+ throw new TypeError('Object-specific response scope requires Case schema 18 or later.');
26
+ const item = exact(value, ['kind', 'identifier', 'incidentTargetId'], 'Response object');
27
+ const kind = enumeration(item.kind, CASE_RESPONSE_OBJECT_KINDS, 'Response object kind');
28
+ const identifier = text(item.identifier, 'Response object identifier', MAX_CASE_RESPONSE_OBJECT_IDENTIFIER);
29
+ if (!identifier || identifier.trim() !== identifier || /[\u0000-\u001f\u007f]/u.test(identifier))
30
+ throw new TypeError('Response object identifier must be a bounded single-line value.');
31
+ const incidentTargetId = item.incidentTargetId === null ? null : text(item.incidentTargetId, 'Incident object identity', 64);
32
+ if (incidentTargetId !== null && !/^[A-Za-z0-9_-]{1,64}$/u.test(incidentTargetId))
33
+ throw new TypeError('Incident object identity is invalid.');
34
+ if (kind === 'domain' || kind === 'hostname') {
35
+ if (identifier !== identifier.toLowerCase() || !isValidAsciiHostname(identifier) || incidentTargetId !== null)
36
+ throw new TypeError('Domain and hostname scope require a normalised hostname and no incident-link identity.');
37
+ }
38
+ else {
39
+ let url;
40
+ try {
41
+ url = new URL(identifier);
42
+ }
43
+ catch {
44
+ throw new TypeError('Select an exact retained HTTP(S) incident link for this object.');
45
+ }
46
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.toString() !== identifier || !incidentTargetId)
47
+ throw new TypeError('Select an exact retained HTTP(S) incident link without credentials for this object.');
48
+ }
49
+ return Object.freeze({ kind, identifier, incidentTargetId });
50
+ }
51
+ export function readCaseResponseObjectOutcome(value, sourceVersion) {
52
+ if (value === undefined)
53
+ return undefined;
54
+ if (sourceVersion != null && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
55
+ throw new TypeError('Object-specific outcomes require Case schema 18 or later.');
56
+ return enumeration(value, CASE_RESPONSE_OBJECT_OUTCOMES, 'Response object outcome');
57
+ }
58
+ export function sameCaseResponseObject(left, right) {
59
+ return left !== undefined && right !== undefined && left.kind === right.kind && left.identifier === right.identifier && left.incidentTargetId === right.incidentTargetId;
60
+ }
61
+ /** Explicit retained snapshots permit follow-up without retargeting edited links. */
62
+ export function retainedCaseResponseObjects(record) {
63
+ const objects = [...(record.actions ?? []).flatMap(action => [...action.responseObjects ?? [], ...action.history.flatMap(event => event.responseObjects ?? [])]),
64
+ ...(record.observedEffects?.reviews ?? []).flatMap(review => review.responseObject ? [review.responseObject] : []),
65
+ ...(record.evidencePins ?? []).flatMap(pin => pin.responseObject ? [pin.responseObject] : []),
66
+ ...(record.closures?.records ?? []).flatMap(closure => closure.responseObject ? [closure.responseObject] : []),
67
+ ...(record.assertions ?? []).flatMap(assertion => assertion.recheck?.responseObject ? [assertion.recheck.responseObject] : [])];
68
+ return [...new Map(objects.map(object => [JSON.stringify(object), object])).values()];
69
+ }
70
+ /** New identities require current metadata; exact historical identities remain usable. */
71
+ export function assertCaseResponseObject(object, record) {
72
+ if (!object)
73
+ return;
74
+ if (object.kind === 'domain') {
75
+ if (object.identifier !== record.domain)
76
+ throw new TypeError('The response domain must match this Case.');
77
+ }
78
+ else if (object.kind === 'hostname') {
79
+ if (object.identifier !== record.domain && !object.identifier.endsWith(`.${record.domain}`))
80
+ throw new TypeError('The response hostname must belong to this Case domain.');
81
+ }
82
+ else if (!record.workflowMetadata?.incidentTargets.some(target => target.id === object.incidentTargetId && target.url === object.identifier)
83
+ && !retainedCaseResponseObjects(record).some(retained => sameCaseResponseObject(retained, object))) {
84
+ throw new TypeError('The selected exact incident object changed or is not retained in this Case. Refresh and review it before saving.');
85
+ }
86
+ }
87
+ export function assertCaseObjectOutcome(outcome, object) {
88
+ if (!outcome)
89
+ return;
90
+ if (!object)
91
+ throw new TypeError('A typed object outcome requires explicit object scope.');
92
+ if (outcome === 'suspended' && object.kind !== 'domain')
93
+ throw new TypeError('Domain suspension requires domain scope.');
94
+ if (outcome === 'restricted' && object.kind !== 'social_account' && object.kind !== 'app' && object.kind !== 'other')
95
+ throw new TypeError('Account restriction requires account, app or explicitly other scope.');
96
+ }
97
+ export function caseResponseObjectChoices(record) {
98
+ const objects = [{ kind: 'domain', identifier: record.domain, incidentTargetId: null }];
99
+ const hosts = new Set([...(record.evidencePins ?? []).map(pin => pin.observationHostname), ...(record.evidenceHistory ?? []).map(snapshot => snapshot.inputHostname)].filter((value) => Boolean(value)));
100
+ for (const identifier of hosts)
101
+ if (identifier === record.domain || identifier.endsWith(`.${record.domain}`))
102
+ objects.push({ kind: 'hostname', identifier, incidentTargetId: null });
103
+ for (const target of record.workflowMetadata?.incidentTargets ?? [])
104
+ for (const kind of CASE_RESPONSE_OBJECT_KINDS.filter(kind => kind !== 'domain' && kind !== 'hostname'))
105
+ objects.push({ kind, identifier: target.url, incidentTargetId: target.id });
106
+ objects.push(...retainedCaseResponseObjects(record));
107
+ return [...new Map(objects.map(object => [JSON.stringify(object), object])).values()].map(object => {
108
+ const historical = Boolean(object.incidentTargetId && !record.workflowMetadata?.incidentTargets.some(target => target.id === object.incidentTargetId && target.url === object.identifier));
109
+ return { value: JSON.stringify(object), label: `${object.kind.replaceAll('_', ' ')} · ${object.identifier}${historical ? ' · historical / no current incident link' : ''}`, object };
110
+ });
111
+ }
112
+ export function selectedCaseResponseObject(record, value) {
113
+ if (!value)
114
+ return undefined;
115
+ const object = caseResponseObjectChoices(record).find(choice => choice.value === value)?.object;
116
+ if (!object)
117
+ throw new TypeError('The selected incident object changed. Refresh and review it before saving.');
118
+ return readCaseResponseObject(object);
119
+ }
@@ -1,7 +1,9 @@
1
1
  // Independent observed effects, linked closures and lifecycle summaries.
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
- import { readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
4
+ import { readCaseRecheckAnswerContext, assertRecheckNonReproduction, COMPARATIVE_CASE_OBJECT_OUTCOMES } from "./case-recheck-model.mjs";
5
+ import { readCaseResponseObject, readCaseResponseObjectOutcome, assertCaseObjectOutcome, sameCaseResponseObject } from "./case-response-object.mjs";
6
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
5
7
  import { CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, } from "./case-response-records.mjs";
6
8
  import { COMPLETENESS, SAFE_ID_RE, boundedCounter, compareCodeUnits, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
7
9
  const OBSERVED_EFFECT_STATES = new Set(CASE_OBSERVED_EFFECT_STATES);
@@ -19,6 +21,16 @@ function normalizeObservedEffectReview(raw, fallback, validPinIds, validSighting
19
21
  return null;
20
22
  const createdAt = iso(item.createdAt, observedAt || fallback, options);
21
23
  const recheck = readCaseRecheckAnswerContext(item.recheck, options.sourceVersion);
24
+ const responseObject = readCaseResponseObject(item.responseObject, options.sourceVersion);
25
+ const objectOutcome = readCaseResponseObjectOutcome(item.objectOutcome, options.sourceVersion);
26
+ assertCaseObjectOutcome(objectOutcome, responseObject);
27
+ if (objectOutcome && (item.state === 'unavailable' || item.state === 'not_checked'))
28
+ throw new TypeError('Unavailable or unchecked collection cannot establish an object outcome.');
29
+ if (objectOutcome && COMPARATIVE_CASE_OBJECT_OUTCOMES.includes(objectOutcome)
30
+ && (item.completeness !== 'complete' || !recheck?.responseObject || recheck.conditionsMatch !== 'comparable'))
31
+ throw new TypeError('An independently observed object state change requires complete evidence and comparable exact-object conditions.');
32
+ if (recheck?.responseObject && !sameCaseResponseObject(recheck.responseObject, responseObject))
33
+ throw new TypeError('The recheck answer and observation must concern the same exact object.');
22
34
  if (recheck && item.state === 'not_reproduced' && (item.completeness !== 'complete' || recheck.conditionsMatch !== 'comparable')) {
23
35
  throw new TypeError('A question cannot be marked not reproduced from incomplete evidence or unconfirmed comparison conditions.');
24
36
  }
@@ -50,6 +62,8 @@ function normalizeObservedEffectReview(raw, fallback, validPinIds, validSighting
50
62
  sightingId,
51
63
  followUpAt: optionalIso(item.followUpAt, options),
52
64
  ...(recheck ? { recheck } : {}),
65
+ ...(responseObject ? { responseObject } : {}),
66
+ ...(objectOutcome ? { objectOutcome } : {}),
53
67
  createdAt,
54
68
  };
55
69
  }
@@ -127,6 +141,7 @@ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options
127
141
  if (!summary)
128
142
  return null;
129
143
  const createdAt = iso(item.createdAt, fallback, options);
144
+ const responseObject = readCaseResponseObject(item.responseObject, options.sourceVersion);
130
145
  const observedEffectReviewId = typeof item.observedEffectReviewId === 'string'
131
146
  && SAFE_ID_RE.test(item.observedEffectReviewId)
132
147
  && (!validReviewIds || validReviewIds.has(item.observedEffectReviewId))
@@ -149,7 +164,8 @@ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options
149
164
  return null;
150
165
  const linkedProviderEvents = actionId ? linkContext.providerResolutionEvents?.get(actionId) ?? [] : [];
151
166
  if (linkContext.providerResolutionEvents && reason === 'provider_reported_resolution_not_independently_checked'
152
- && !linkedProviderEvents.some((event) => Date.parse(event.occurredAt) <= Date.parse(createdAt)))
167
+ && !linkedProviderEvents.some((event) => Date.parse(event.occurredAt) <= Date.parse(createdAt)
168
+ && (responseObject ? event.responseObjects?.some(object => sameCaseResponseObject(object, responseObject)) : !event.responseObjects?.length)))
153
169
  return null;
154
170
  const linkLimitations = [
155
171
  ...(item.observedEffectReviewId != null && !observedEffectReviewId ? ['A malformed or dangling observed-effect review reference was omitted from this closure.'] : []),
@@ -157,6 +173,7 @@ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options
157
173
  ];
158
174
  return {
159
175
  id: safeId(item.id, 'case-closure', { reason: item.reason, summary, createdAt }),
176
+ ...(responseObject === undefined ? {} : { responseObject }),
160
177
  reason,
161
178
  summary,
162
179
  observedEffectReviewId,
@@ -173,8 +190,11 @@ export function buildCaseClosureLinkContext(observedEffects, actions) {
173
190
  createdAt: review.createdAt,
174
191
  }])),
175
192
  providerResolutionEvents: new Map(actions.map((action) => [action.id, action.history
176
- .filter((event) => event.applied && event.providerOutcome === 'provider_reports_resolved')
177
- .map((event) => ({ eventId: event.id, occurredAt: event.occurredAt }))])),
193
+ // Reconciliation can change projection, but not the identity of a retained
194
+ // typed receipt supporting an already-authored historical decision.
195
+ .filter((event) => event.providerOutcome === 'provider_reports_resolved')
196
+ .map((event) => ({ eventId: event.id, occurredAt: event.occurredAt,
197
+ ...(event.responseObjects === undefined ? {} : { responseObjects: event.responseObjects }) }))])),
178
198
  };
179
199
  }
180
200
  export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, validActionIds, options = {}, linkContext = {}) {
@@ -222,7 +242,82 @@ export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, valid
222
242
  ]),
223
243
  };
224
244
  }
225
- export function appendCaseClosure(current, raw, now, observedEffects, actions) {
245
+ /** New-write policy only. Historical normalisation must not re-adjudicate a closure. */
246
+ export function caseClosureReviewBlocker(reason, review, now, responseObject, evidencePins = []) {
247
+ if (review?.responseObject && !sameCaseResponseObject(responseObject, review.responseObject))
248
+ return 'This independent review concerns one object. Select that object for closure; it cannot close the whole Case.';
249
+ if (reason !== 'independently_not_reproduced' && reason !== 'infrastructure_changed')
250
+ return null;
251
+ const expected = reason === 'independently_not_reproduced' ? 'not_reproduced' : 'changed';
252
+ const invalidLink = reason === 'independently_not_reproduced'
253
+ ? 'This closure reason requires a linked independent not-reproduced review.'
254
+ : 'This closure reason requires a linked independent changed review.';
255
+ if (!review || review.state !== expected)
256
+ return invalidLink;
257
+ if (responseObject) {
258
+ if (!sameCaseResponseObject(responseObject, review.responseObject))
259
+ return 'Object-specific technical closure requires a linked independent review explicitly bound to this exact object; historical missing binding remains unknown.';
260
+ if (!review.recheck || !sameCaseResponseObject(responseObject, review.recheck.responseObject))
261
+ return 'Object-specific technical closure requires a complete exact-object baseline and current review under comparable conditions.';
262
+ try {
263
+ assertRecheckNonReproduction('not_reproduced', review.recheck, review.completeness, evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), review.observedAt);
264
+ }
265
+ catch (cause) {
266
+ return cause instanceof Error ? cause.message : 'The exact-object review cannot support technical closure.';
267
+ }
268
+ }
269
+ const observedAt = normalizeExplicitIsoTimestamp(review.observedAt);
270
+ const createdAt = normalizeExplicitIsoTimestamp(review.createdAt);
271
+ const closedAt = normalizeExplicitIsoTimestamp(now);
272
+ if (!observedAt || !createdAt || !closedAt
273
+ || Date.parse(observedAt) > Date.parse(closedAt)
274
+ || Date.parse(createdAt) > Date.parse(closedAt))
275
+ return invalidLink;
276
+ if (reason === 'independently_not_reproduced') {
277
+ if (review.completeness !== 'complete')
278
+ return 'Independent non-reproduction closure requires a complete observation. Keep this limited review and collect or record a complete recheck.';
279
+ if (review.recheck && review.recheck.conditionsMatch !== 'comparable')
280
+ return 'Independent non-reproduction closure requires comparable recheck conditions.';
281
+ }
282
+ return null;
283
+ }
284
+ /** Latest applicable provider cohort, never the action-wide summary or an old receipt. */
285
+ export function caseClosureProviderBlocker(action, responseObject, now) {
286
+ const blocked = 'This closure reason requires a linked typed provider-reported-resolution outcome for the latest applicable object observation.';
287
+ const closedAt = normalizeExplicitIsoTimestamp(now);
288
+ if (!action || !closedAt)
289
+ return blocked;
290
+ if (responseObject ? !action.responseObjects?.some(object => sameCaseResponseObject(object, responseObject)) : action.responseObjects?.length)
291
+ return blocked;
292
+ // A losing workflow transition is still retained evidence. Select the
293
+ // exact-object cohort before checking whether its receipts were applied.
294
+ const events = action.history.filter(event => (event.providerOutcome !== null || event.objectOutcome !== undefined)
295
+ && Date.parse(event.occurredAt) <= Date.parse(closedAt)
296
+ && (responseObject ? event.responseObjects?.some(object => sameCaseResponseObject(object, responseObject)) : !event.responseObjects?.length));
297
+ const cohort = latestObservationCohort(events, event => event.occurredAt);
298
+ if (!cohort.latest.length || cohort.undated.length || cohort.latest.some(event => !event.applied || event.providerOutcome !== 'provider_reports_resolved'
299
+ || event.objectOutcome === 'restored' || event.objectOutcome === 'disputed')
300
+ || new Set(cohort.latest.map(event => event.objectOutcome ?? null)).size !== 1)
301
+ return blocked;
302
+ return null;
303
+ }
304
+ export function caseClosureActionBlocker(reason, action, responseObject, now) {
305
+ if (action?.responseObjects?.length && (!responseObject || !action.responseObjects.some(object => sameCaseResponseObject(object, responseObject))))
306
+ return 'This action concerns explicitly bound objects. Select one of them for this closure; other objects remain independent.';
307
+ return reason === 'provider_reported_resolution_not_independently_checked' ? caseClosureProviderBlocker(action, responseObject, now) : null;
308
+ }
309
+ /** Derived presentation only: never rewrite the analyst's summary or limitations. */
310
+ export function caseClosureHistoryQualification(closure, actions) {
311
+ if (closure.reason !== 'provider_reported_resolution_not_independently_checked')
312
+ return null;
313
+ const action = actions.find(candidate => candidate.id === closure.actionId);
314
+ // Evaluate the retained history, not a wall-clock freshness claim. Future-dated
315
+ // or conflicting receipts cannot silently make an old decision authoritative.
316
+ const latestAt = (action?.history ?? []).reduce((latest, event) => Date.parse(event.occurredAt) > Date.parse(latest) ? event.occurredAt : latest, closure.createdAt);
317
+ return caseClosureProviderBlocker(action, closure.responseObject, latestAt) === null ? null
318
+ : 'As of the latest retained receipt, provider history does not support a new closure for this scope. This historical analyst decision is preserved; it does not establish current remediation.';
319
+ }
320
+ export function appendCaseClosure(current, raw, now, observedEffects, actions, evidencePins = []) {
226
321
  const item = record(raw);
227
322
  const reason = typeof item.reason === 'string' && CLOSURE_REASONS.has(item.reason)
228
323
  ? item.reason
@@ -233,21 +328,13 @@ export function appendCaseClosure(current, raw, now, observedEffects, actions) {
233
328
  const action = typeof item.actionId === 'string'
234
329
  ? actions.find((candidate) => candidate.id === item.actionId) ?? null
235
330
  : 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
- }
244
- if (reason === 'provider_reported_resolution_not_independently_checked'
245
- && (action?.providerOutcome !== 'provider_reports_resolved'
246
- || !action.history.some((event) => event.applied
247
- && event.providerOutcome === 'provider_reports_resolved'
248
- && Date.parse(event.occurredAt) <= Date.parse(now)))) {
249
- throw new Error('This closure reason requires a linked typed provider-reported-resolution outcome.');
250
- }
331
+ const responseObject = readCaseResponseObject(item.responseObject);
332
+ const reviewBlocker = caseClosureReviewBlocker(reason, review, now, responseObject, evidencePins);
333
+ if (reviewBlocker)
334
+ throw new Error(reviewBlocker);
335
+ const actionBlocker = caseClosureActionBlocker(reason, action, responseObject, now);
336
+ if (actionBlocker)
337
+ throw new Error(actionBlocker);
251
338
  const linkContext = buildCaseClosureLinkContext(observedEffects, actions);
252
339
  const created = normalizeClosure({ ...item, id: freshId('case-closure'), createdAt: now }, now, new Set(observedEffects.reviews.map((candidate) => candidate.id)), new Set(actions.map((candidate) => candidate.id)), {}, linkContext);
253
340
  if (!created || !reason)
@@ -293,6 +380,7 @@ export function buildCaseResponseLifecycleSummary(input) {
293
380
  ? 'missing'
294
381
  : latestObservedChangeAt ? 'available' : 'ambiguous';
295
382
  const latestClosure = [...(input.closures?.records ?? [])]
383
+ .filter(closure => closure.responseObject === undefined)
296
384
  .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || compareCodeUnits(right.id, left.id))[0] ?? null;
297
385
  return {
298
386
  providerOutcomeState,