@slicedearth/whoisleuth-cli 2.3.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 (418) hide show
  1. package/NOTICE +8 -2
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/argument-values.mjs +55 -0
  7. package/cli/arguments.mjs +24 -408
  8. package/cli/artifact-validation/case-response.mjs +65 -44
  9. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  10. package/cli/artifact-validation/lookup-evidence.mjs +15 -9
  11. package/cli/artifact-validation/signed-review.mjs +136 -68
  12. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  13. package/cli/artifact-verify.mjs +92 -24
  14. package/cli/assurance-arguments.mjs +56 -0
  15. package/cli/assurance-command-definitions.mjs +179 -0
  16. package/cli/assurance-command-runner.mjs +40 -5
  17. package/cli/bagit-folder.mjs +57 -0
  18. package/cli/bagit-review.mjs +23 -0
  19. package/cli/bounded-json.mjs +1 -1
  20. package/cli/bulk-checkpoint.mjs +1 -1
  21. package/cli/bulk-command-runner.mjs +4 -2
  22. package/cli/bulk-output.mjs +29 -3
  23. package/cli/bulk.mjs +2 -3
  24. package/cli/case-command.mjs +212 -0
  25. package/cli/case-pack.mjs +50 -32
  26. package/cli/ci-report.mjs +6 -3
  27. package/cli/collection-arguments.mjs +123 -0
  28. package/cli/collection-command-definitions.mjs +330 -0
  29. package/cli/collection-preflight.mjs +8 -1
  30. package/cli/command-argument-grammar.mjs +8 -206
  31. package/cli/command-definition.mjs +329 -0
  32. package/cli/command-reference.mjs +94 -1357
  33. package/cli/command-seeds.mjs +18 -0
  34. package/cli/compare.mjs +1 -1
  35. package/cli/completion.mjs +298 -79
  36. package/cli/config-profile.mjs +8 -6
  37. package/cli/ct-event-intake.mjs +16 -16
  38. package/cli/discover.mjs +5 -4
  39. package/cli/discovery-observation-snapshot.mjs +46 -18
  40. package/cli/discovery-scan-command-runner.mjs +5 -3
  41. package/cli/discovery-scan.mjs +7 -4
  42. package/cli/discovery-snapshot.mjs +2 -2
  43. package/cli/discovery-workflow.mjs +5 -5
  44. package/cli/domain-control-monitor.mjs +50 -20
  45. package/cli/domain-control-observations.mjs +151 -103
  46. package/cli/errors.mjs +37 -6
  47. package/cli/evidence-arguments.mjs +46 -0
  48. package/cli/evidence-command-definitions.mjs +69 -0
  49. package/cli/evidence-command-runner.mjs +18 -8
  50. package/cli/evidence-signing.mjs +5 -4
  51. package/cli/external-observation-mapping.mjs +9 -10
  52. package/cli/formatters/terminal-command-formats.mjs +6 -0
  53. package/cli/formatters/terminal-lookup.mjs +24 -46
  54. package/cli/formatters/terminal-shared.mjs +5 -3
  55. package/cli/formatters/terminal-technology.mjs +114 -0
  56. package/cli/history-arguments.mjs +39 -0
  57. package/cli/history-command-definitions.mjs +88 -0
  58. package/cli/indicator-set-command.mjs +33 -0
  59. package/cli/inline-command-families.mjs +6 -46
  60. package/cli/inline-command-runner.mjs +4 -31
  61. package/cli/input.mjs +2 -2
  62. package/cli/intake-command.mjs +107 -0
  63. package/cli/interactive-launcher.mjs +2 -160
  64. package/cli/interchange-report.mjs +8 -10
  65. package/cli/investigation-artifacts.mjs +176 -0
  66. package/cli/investigation-checkpoint.mjs +10 -0
  67. package/cli/investigation-folder.mjs +114 -0
  68. package/cli/investigation-manifest.mjs +1 -112
  69. package/cli/investigation-package-review.mjs +103 -0
  70. package/cli/investigation-plan.mjs +38 -147
  71. package/cli/investigation-recipes.mjs +177 -0
  72. package/cli/investigation-run.mjs +256 -35
  73. package/cli/local-document-checkpoint.mjs +170 -0
  74. package/cli/local-mmdb-review.mjs +197 -17
  75. package/cli/lookalike-calibration.mjs +1 -1
  76. package/cli/lookup-brief.mjs +2 -2
  77. package/cli/lookup-browser.mjs +5 -12
  78. package/cli/lookup-command-runner.mjs +33 -39
  79. package/cli/lookup-diff.mjs +47 -12
  80. package/cli/lookup-plan.mjs +16 -14
  81. package/cli/lookup-reconcile.mjs +15 -12
  82. package/cli/lookup-timeline.mjs +7 -6
  83. package/cli/mail-header-review.mjs +24 -247
  84. package/cli/mail-review.mjs +75 -21
  85. package/cli/manual.mjs +1 -0
  86. package/cli/network-arguments.mjs +51 -0
  87. package/cli/network-command-definitions.mjs +130 -0
  88. package/cli/network-command-runner.mjs +45 -31
  89. package/cli/offline-evidence-review.mjs +39 -6
  90. package/cli/output-file.mjs +31 -5
  91. package/cli/posture.mjs +1 -1
  92. package/cli/registry-doctor.mjs +3 -1
  93. package/cli/registry-fixture-scaffold.mjs +1 -1
  94. package/cli/registry-support.mjs +1 -1
  95. package/cli/retained-artifact-diff.mjs +10 -9
  96. package/cli/review-arguments.mjs +131 -0
  97. package/cli/review-command-definitions.mjs +273 -0
  98. package/cli/review-command-runner.mjs +104 -149
  99. package/cli/risk-calibration.mjs +8 -8
  100. package/cli/runner.mjs +74 -37
  101. package/cli/saved-lookup.mjs +2 -2
  102. package/cli/selected-input-worker.mjs +63 -0
  103. package/cli/sharing-review.mjs +8 -80
  104. package/cli/signer-trust.mjs +104 -0
  105. package/cli/source-reliability.mjs +75 -84
  106. package/cli/support-arguments.mjs +49 -0
  107. package/cli/support-command-definitions.mjs +209 -0
  108. package/cli/support-command-runner.mjs +2 -2
  109. package/cli/terminal-input.mjs +168 -0
  110. package/cli/workflow-arguments.mjs +86 -0
  111. package/cli/workflow-command-definitions.mjs +78 -0
  112. package/cli/workflow-command-runner.mjs +75 -13
  113. package/docs/cli-reference.md +103 -30
  114. package/docs/cli.md +510 -25
  115. package/docs/contextual-reviews.md +155 -0
  116. package/lib/abort.mjs +4 -1
  117. package/lib/analyst-taxonomy.mjs +2 -40
  118. package/lib/application-version.mjs +1 -1
  119. package/lib/availability.mjs +27 -754
  120. package/lib/bounded-contract-normalizers.mjs +4 -0
  121. package/lib/bounded-file.mjs +3 -1
  122. package/lib/bounded-html-document.mjs +125 -0
  123. package/lib/bounded-json.mjs +2 -290
  124. package/lib/browser-library-profile.mjs +99 -130
  125. package/lib/browser-library-worker.mjs +112 -0
  126. package/lib/candidate-provenance-bounds.mjs +2 -0
  127. package/lib/classify.mjs +1 -1
  128. package/lib/client-behavior-profile.mjs +3 -3
  129. package/lib/ct-query.mjs +2 -27
  130. package/lib/ct-response-bounds.mjs +2 -9
  131. package/lib/ct-search.mjs +89 -46
  132. package/lib/dmarc-discovery.mjs +62 -0
  133. package/lib/dns-convergence-review.mjs +21 -8
  134. package/lib/dns-delegation-health.mjs +36 -22
  135. package/lib/dns-inheritance-review.mjs +207 -0
  136. package/lib/dns-intelligence.mjs +44 -29
  137. package/lib/dns-mx.mjs +1 -1
  138. package/lib/dns-query-coverage.mjs +40 -0
  139. package/lib/dnssec-evidence-validation.mjs +11 -3
  140. package/lib/domain-assurance.mjs +1 -1
  141. package/lib/domain-change-packet.mjs +18 -14
  142. package/lib/domain-change-review.mjs +40 -16
  143. package/lib/domain-control-flight-recorder.mjs +301 -124
  144. package/lib/domain-control-manifest.mjs +126 -58
  145. package/lib/domain-posture-analysis.mjs +41 -11
  146. package/lib/domain-posture-parsers.mjs +88 -6
  147. package/lib/domain-posture.mjs +120 -32
  148. package/lib/domain-registration.mjs +392 -0
  149. package/lib/evidence-export-privacy.mjs +568 -0
  150. package/lib/evidence-export.mjs +12 -593
  151. package/lib/evidence-report.mjs +7 -2
  152. package/lib/external-intelligence-risk.mjs +2 -124
  153. package/lib/favicon.mjs +117 -41
  154. package/lib/generated/cisa-kev-catalog.mjs +50 -4
  155. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  156. package/lib/generated/retire-browser-catalog.mjs +2742 -1034
  157. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  158. package/lib/html-signals.mjs +87 -85
  159. package/lib/http-evidence-bounds.mjs +2 -7
  160. package/lib/http-intelligence.mjs +52 -27
  161. package/lib/idn-confusable-policy.mjs +6 -4
  162. package/lib/idn-confusables.mjs +1 -1
  163. package/lib/interchange-fidelity-registry.mjs +10 -0
  164. package/lib/lookup-cache.mjs +34 -11
  165. package/lib/lookup-child-profile-contract.mjs +24 -15
  166. package/lib/lookup-contract-primitives.mjs +1 -1
  167. package/lib/lookup-diagnostics.mjs +2 -1
  168. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  169. package/lib/lookup-progress.mjs +79 -13
  170. package/lib/lookup-response-contract.mjs +1077 -0
  171. package/lib/lookup-response.mjs +11 -24
  172. package/lib/lookup-source-progress.mjs +24 -35
  173. package/lib/lookup.mjs +49 -28
  174. package/lib/observed-network-context.mjs +43 -27
  175. package/lib/opportunity-scoring.mjs +2 -107
  176. package/lib/outbound-identity.mjs +1 -1
  177. package/lib/outbound-request-bounds.mjs +4 -1
  178. package/lib/page-fingerprints.mjs +87 -134
  179. package/lib/page-role-profile.mjs +12 -8
  180. package/lib/perceptual-hash-comparison.mjs +2 -34
  181. package/lib/perceptual-hash.mjs +79 -24
  182. package/lib/portable-generator.mjs +2 -29
  183. package/lib/project-metadata.mjs +2 -8
  184. package/lib/rdap-bootstrap.mjs +72 -22
  185. package/lib/rdap-client.mjs +26 -14
  186. package/lib/rdap-normalization.mjs +33 -18
  187. package/lib/rdap-search-workbench.mjs +1 -1
  188. package/lib/rdap-transport.mjs +47 -20
  189. package/lib/rdap-types.mjs +2 -1
  190. package/lib/rdap.mjs +18 -3
  191. package/lib/registrable-domain.mjs +2 -36
  192. package/lib/registrar-standing-contract.mjs +2 -1
  193. package/lib/registrar-standing.mjs +9 -13
  194. package/lib/registry-capabilities.mjs +7 -3
  195. package/lib/registry-capability-catalogue.mjs +29 -20
  196. package/lib/registry-dates.mjs +2 -190
  197. package/lib/registry-insights.mjs +56 -18
  198. package/lib/response-policy.mjs +86 -48
  199. package/lib/risk-calibration-summary.mjs +2 -338
  200. package/lib/risk-scoring.mjs +2 -345
  201. package/lib/rpki-evidence.mjs +8 -5
  202. package/lib/safe-fetch.mjs +54 -47
  203. package/lib/scoring-evidence-quality.mjs +2 -107
  204. package/lib/security-txt.mjs +10 -7
  205. package/lib/semantic-version.mjs +2 -54
  206. package/lib/service-binding-dns.mjs +35 -0
  207. package/lib/smtp-transport-review.mjs +75 -34
  208. package/lib/sslbl-certificates.generated.mjs +141 -138
  209. package/lib/static-html-analysis.mjs +117 -146
  210. package/lib/structured-data-identity.mjs +2 -1
  211. package/lib/technology-markup.mjs +100 -0
  212. package/lib/threat-intelligence-manifests.mjs +11 -139
  213. package/lib/threat-intelligence-runtime.mjs +24 -52
  214. package/lib/threat-intelligence-targets.mjs +6 -34
  215. package/lib/threat-intelligence-types.mjs +2 -36
  216. package/lib/typosquat-generator.mjs +29 -16
  217. package/lib/urlhaus-intelligence.mjs +8 -3
  218. package/lib/urlscan-intelligence.mjs +1 -1
  219. package/lib/website-enrichment.mjs +388 -0
  220. package/lib/website-security-posture.mjs +30 -6
  221. package/lib/website-technology.mjs +111 -71
  222. package/lib/whois-authority.mjs +55 -5
  223. package/lib/whois-chain.mjs +9 -2
  224. package/lib/whois-common-formats.mjs +5 -5
  225. package/lib/whois-contracts.mjs +2 -1
  226. package/lib/whois-dialects.mjs +2 -2
  227. package/lib/whois-normalization.mjs +3 -1
  228. package/lib/whois-parser-support.mjs +8 -1
  229. package/lib/whois-parser.mjs +5 -3
  230. package/lib/whois-transport.mjs +23 -7
  231. package/package.json +14 -14
  232. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  233. package/packages/analysis/bounded-json.mjs +330 -0
  234. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  235. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  236. package/packages/analysis/ct-query.mjs +27 -0
  237. package/packages/analysis/ct-response-bounds.mjs +9 -0
  238. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  239. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  240. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  241. package/packages/analysis/opportunity-scoring.mjs +107 -0
  242. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  243. package/packages/analysis/portable-generator.mjs +29 -0
  244. package/packages/analysis/project-metadata.mjs +8 -0
  245. package/packages/analysis/registrable-domain.mjs +56 -0
  246. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  247. package/packages/analysis/risk-scoring.mjs +345 -0
  248. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  249. package/packages/analysis/semantic-version.mjs +54 -0
  250. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  251. package/packages/cases/case-attachment-model.mjs +83 -0
  252. package/packages/cases/case-evidence-links.mjs +135 -0
  253. package/packages/cases/case-evidence-model.mjs +229 -69
  254. package/packages/cases/case-evidence-quality.mjs +5 -0
  255. package/packages/cases/case-export-input.mjs +55 -0
  256. package/packages/cases/case-incident-context.mjs +52 -0
  257. package/packages/cases/case-markdown.mjs +11 -0
  258. package/packages/cases/case-migration-model.mjs +98 -48
  259. package/packages/cases/case-model.mjs +1 -0
  260. package/packages/cases/case-recheck-model.mjs +149 -0
  261. package/packages/cases/case-record-contracts.mjs +1 -1
  262. package/packages/cases/case-record-core.mjs +4 -3
  263. package/packages/cases/case-record-operations.mjs +123 -133
  264. package/packages/cases/case-record-projection.mjs +30 -20
  265. package/packages/cases/case-record-version-input.mjs +19 -0
  266. package/packages/cases/case-report.mjs +114 -67
  267. package/packages/cases/case-requested-evidence.mjs +146 -0
  268. package/packages/cases/case-response-actions.mjs +756 -0
  269. package/packages/cases/case-response-model.mjs +49 -1129
  270. package/packages/cases/case-response-outcomes.mjs +338 -0
  271. package/packages/cases/case-response-packet-render.mjs +157 -0
  272. package/packages/cases/case-response-packet-types.mjs +1 -0
  273. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  274. package/packages/cases/case-response-packet.mjs +58 -593
  275. package/packages/cases/case-response-records.mjs +93 -0
  276. package/packages/cases/case-response-review-inputs.mjs +382 -0
  277. package/packages/cases/case-response-values.mjs +85 -0
  278. package/packages/cases/case-review-package.mjs +45 -0
  279. package/packages/cases/case-selection.mjs +46 -0
  280. package/packages/cases/case-storage-model.mjs +26 -1
  281. package/packages/cases/case-workflow-metadata.mjs +225 -0
  282. package/packages/cases/case-workflow-migration.mjs +43 -0
  283. package/packages/cases/http-summary.mjs +6 -1
  284. package/packages/cases/response-route-freshness.mjs +16 -0
  285. package/packages/comparison/capture-context.mjs +61 -0
  286. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  287. package/packages/comparison/favicon-similarity.mjs +1 -1
  288. package/packages/comparison/page-similarity.mjs +3 -2
  289. package/packages/comparison/relationship-evidence.mjs +103 -14
  290. package/packages/comparison/relationship-provenance.mjs +99 -0
  291. package/packages/contracts/analyst-interchange.mjs +234 -0
  292. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  293. package/packages/contracts/analyst-review-state.mjs +5 -4
  294. package/packages/contracts/case-portability.mjs +370 -114
  295. package/packages/contracts/case-views-contract.mjs +12 -0
  296. package/packages/contracts/case-views.mjs +26 -0
  297. package/packages/contracts/cli-command-semantics.mjs +3 -0
  298. package/packages/contracts/cli-grammar.mjs +6 -0
  299. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  300. package/packages/contracts/cli-lookup.mjs +0 -1
  301. package/packages/contracts/context-review.mjs +24 -0
  302. package/packages/contracts/document-intake.mjs +11 -0
  303. package/packages/contracts/domain-control-flight-recorder.mjs +98 -44
  304. package/packages/contracts/domain-control-manifest.mjs +128 -74
  305. package/packages/contracts/domain-control-monitor.mjs +59 -45
  306. package/packages/contracts/domain-control-review.mjs +200 -121
  307. package/packages/contracts/domain-name.mjs +21 -0
  308. package/packages/contracts/external-observation-interchange.mjs +2 -1
  309. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  310. package/packages/contracts/har-review.mjs +2 -0
  311. package/packages/contracts/http-url.mjs +2 -0
  312. package/packages/contracts/identity-events.mjs +4 -0
  313. package/packages/contracts/investigation-package-limits.mjs +24 -0
  314. package/packages/contracts/investigation-portability.mjs +56 -27
  315. package/packages/contracts/investigation-run.mjs +18 -0
  316. package/packages/contracts/ip-address.mjs +44 -0
  317. package/packages/contracts/lookup-evidence.mjs +35 -0
  318. package/packages/contracts/mail-authentication.mjs +5 -0
  319. package/packages/contracts/message-intake.mjs +31 -0
  320. package/packages/contracts/offline-comparison.mjs +4 -2
  321. package/packages/contracts/page-fingerprints.mjs +15 -0
  322. package/packages/contracts/passive-mail.mjs +10 -0
  323. package/packages/contracts/risk-calibration.mjs +0 -1
  324. package/packages/contracts/schema-compatibility.mjs +1 -1
  325. package/packages/contracts/schema-lifecycle.mjs +83 -153
  326. package/packages/contracts/selected-file-limits.mjs +6 -0
  327. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  328. package/packages/contracts/web-capture.mjs +10 -0
  329. package/packages/contracts/workspace-portability.mjs +219 -238
  330. package/packages/evidence/artifact-integrity.mjs +8 -2
  331. package/packages/evidence/artifact-structure.mjs +105 -0
  332. package/packages/evidence/collection-quality.mjs +67 -0
  333. package/packages/evidence/decision-fact.mjs +4 -1
  334. package/packages/evidence/dns-query-outcome.mjs +72 -0
  335. package/packages/evidence/domain-control-runtime.mjs +155 -27
  336. package/packages/evidence/domain-posture-context.mjs +79 -0
  337. package/packages/evidence/email-recipient.mjs +18 -0
  338. package/packages/evidence/image-regions.mjs +56 -0
  339. package/packages/evidence/latest-observations.mjs +28 -0
  340. package/packages/evidence/lookup-target.mjs +108 -0
  341. package/packages/evidence/observation.mjs +18 -1
  342. package/packages/evidence/passphrase-encryption.mjs +44 -0
  343. package/packages/evidence/png-pixels.mjs +117 -0
  344. package/packages/evidence/public-address-policy.mjs +83 -0
  345. package/packages/evidence/record-identity.mjs +72 -0
  346. package/packages/evidence/registry-dates.mjs +190 -0
  347. package/packages/evidence/retained-file.mjs +69 -0
  348. package/packages/evidence/review-age.mjs +14 -0
  349. package/packages/interchange/bagit.mjs +342 -0
  350. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  351. package/packages/interchange/crc32.mjs +17 -0
  352. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  353. package/packages/interchange/external-findings-import.mjs +93 -61
  354. package/packages/interchange/managed-indicator-export.mjs +35 -0
  355. package/packages/interchange/managed-indicator-set.mjs +210 -0
  356. package/packages/interchange/misp-indicator-export.mjs +93 -0
  357. package/packages/interchange/sharing-policy.mjs +56 -0
  358. package/packages/interchange/stix-indicator-export.mjs +129 -0
  359. package/packages/interchange/web-capture-import.mjs +389 -0
  360. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  361. package/packages/investigation/bulk-mail-exposure.mjs +9 -14
  362. package/packages/investigation/capture-coverage.mjs +57 -0
  363. package/packages/investigation/connector-provenance-review.mjs +100 -0
  364. package/packages/investigation/context-review.mjs +35 -0
  365. package/packages/investigation/document-intake.mjs +81 -0
  366. package/packages/investigation/docx-intake.mjs +133 -0
  367. package/packages/investigation/domain-history-review.mjs +100 -0
  368. package/packages/investigation/domain-transition-review.mjs +47 -0
  369. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  370. package/packages/investigation/har-intake.mjs +68 -0
  371. package/packages/investigation/identity-event-intake.mjs +131 -0
  372. package/packages/investigation/identity-incident-review.mjs +39 -0
  373. package/packages/investigation/incident-sequence-review.mjs +58 -0
  374. package/packages/investigation/intake-report.mjs +19 -0
  375. package/packages/investigation/investigation-bagit.mjs +14 -0
  376. package/packages/investigation/investigation-capsule.mjs +33 -5
  377. package/packages/investigation/investigation-manifest.mjs +200 -0
  378. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  379. package/packages/investigation/investigation-package.mjs +200 -0
  380. package/packages/investigation/link-intake.mjs +179 -0
  381. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  382. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  383. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  384. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  385. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  386. package/packages/investigation/mail-authentication-review.mjs +222 -0
  387. package/packages/investigation/mail-header-identity.mjs +209 -0
  388. package/packages/investigation/message-intake.mjs +195 -0
  389. package/packages/investigation/page-behaviour.mjs +113 -0
  390. package/packages/investigation/pdf-intake.mjs +155 -0
  391. package/packages/investigation/platform-continuity-review.mjs +60 -0
  392. package/packages/investigation/qr-intake.mjs +56 -0
  393. package/packages/investigation/requested-action-hints.mjs +11 -0
  394. package/packages/investigation/selected-input-review.mjs +17 -0
  395. package/packages/investigation/storefront-review.mjs +70 -0
  396. package/packages/monitoring/analyst-review-state.mjs +38 -90
  397. package/packages/workspace/brand-profile-model.mjs +160 -22
  398. package/packages/workspace/bulk-columns.mjs +17 -0
  399. package/packages/workspace/bulk-review-model.mjs +39 -31
  400. package/packages/workspace/bulk-session-model.mjs +112 -27
  401. package/packages/workspace/bulk-sort.mjs +1 -0
  402. package/packages/workspace/campaign-model.mjs +1 -1
  403. package/packages/workspace/case-views.mjs +102 -0
  404. package/packages/workspace/detection-rule-model.mjs +1 -1
  405. package/packages/workspace/hostile-input.mjs +29 -8
  406. package/packages/workspace/investigation-guide.mjs +2 -2
  407. package/packages/workspace/investigation-template-model.mjs +45 -6
  408. package/packages/workspace/page-baseline.mjs +20 -17
  409. package/packages/workspace/relationship-observation-model.mjs +54 -35
  410. package/packages/workspace/shortlist-model.mjs +14 -5
  411. package/packages/workspace/watchlist-history.mjs +30 -14
  412. package/packages/workspace/watchlist-store.mjs +24 -6
  413. package/packages/workspace/website-snapshot-model.mjs +98 -33
  414. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  415. package/packages/workspace/workspace-archive.mjs +90 -25
  416. package/third-party-notices.txt +577 -17
  417. package/lib/hostname.mjs +0 -38
  418. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,10 +1,13 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { canonicalDomainControlRecords } from "../packages/evidence/domain-control-runtime.mjs";
2
3
  import { requireBoundedString, requireDomainName, requireIsoTimestamp, } from "./bounded-contract-normalizers.mjs";
3
- import { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, DOMAIN_CONTROL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS, DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_ROOT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, DOMAIN_CONTROL_FLIGHT_RECORDER_WINDOW_KEYS, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_BYTES, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_OUTPUT_BYTES, MAX_FLIGHT_RECORDER_FIELDS, MAX_FLIGHT_RECORDER_EVENTS, MAX_FLIGHT_RECORDER_INPUT_VALUES, MAX_FLIGHT_RECORDER_JSON_DEPTH, MAX_FLIGHT_RECORDER_JSON_VALUES, MAX_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_SOURCE_LENGTH, MAX_FLIGHT_RECORDER_VALUE_LENGTH, MAX_FLIGHT_RECORDER_VALUES, MAX_FLIGHT_RECORDER_WINDOWS, MAX_FLIGHT_RECORDER_WINDOW_ID_LENGTH, MAX_FLIGHT_RECORDER_WINDOW_REASON_LENGTH, MIN_FLIGHT_RECORDER_OBSERVATIONS, } from "../packages/contracts/domain-control-flight-recorder.mjs";
4
+ import { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, DOMAIN_CONTROL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS, DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_ROOT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS, SOURCE_TIMED_FLIGHT_RECORDER_EVENT_KEYS, SOURCE_TIMED_FLIGHT_RECORDER_LIMITATIONS, FLIGHT_RECORDER_CHANGE_INTERVAL_KEYS, HISTORICAL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS, PUBLIC_MAX_FLIGHT_RECORDER_INPUT_VALUES, PUBLIC_MAX_FLIGHT_RECORDER_VALUES, PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH, SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS, DOMAIN_CONTROL_FLIGHT_RECORDER_WINDOW_KEYS, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_BYTES, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_OUTPUT_BYTES, MAX_FLIGHT_RECORDER_FIELDS, MAX_FLIGHT_RECORDER_EVENTS, MAX_FLIGHT_RECORDER_INPUT_VALUES, MAX_FLIGHT_RECORDER_JSON_DEPTH, MAX_FLIGHT_RECORDER_JSON_VALUES, MAX_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_SOURCE_LENGTH, MAX_FLIGHT_RECORDER_VALUE_LENGTH, MAX_FLIGHT_RECORDER_VALUES, MAX_FLIGHT_RECORDER_WINDOWS, MAX_FLIGHT_RECORDER_WINDOW_ID_LENGTH, MAX_FLIGHT_RECORDER_WINDOW_REASON_LENGTH, MIN_FLIGHT_RECORDER_OBSERVATIONS, } from "../packages/contracts/domain-control-flight-recorder.mjs";
4
5
  export { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, MAX_FLIGHT_RECORDER_FIELDS, MAX_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_VALUES, MAX_FLIGHT_RECORDER_WINDOWS, };
5
6
  const ROOT_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_KEYS);
6
7
  const OBSERVATION_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS);
7
8
  const FIELD_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS);
9
+ const PUBLIC_OBSERVATION_KEYS = new Set(PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS);
10
+ const PUBLIC_FIELD_KEYS = new Set(PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS);
8
11
  const WINDOW_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_WINDOW_KEYS);
9
12
  const DOCUMENT_ROOT_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_ROOT_KEYS);
10
13
  const EVENT_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS);
@@ -217,22 +220,23 @@ function assertPrettyJsonByteBudget(value, maximum, label) {
217
220
  throw new TypeError(`${label} must be serialisable as bounded JSON.`);
218
221
  }
219
222
  }
220
- function snapshotFlightRecorderInput(input) {
223
+ function snapshotFlightRecorderInput(input, legacy) {
221
224
  const observations = boundedDataArray(input.observations, 'Domain control flight-recorder input observations', MIN_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_OBSERVATIONS).map((raw, index) => {
222
- const item = exactRecord(raw, OBSERVATION_KEYS, `observations[${index}]`);
225
+ const item = exactRecord(raw, legacy ? PUBLIC_OBSERVATION_KEYS : OBSERVATION_KEYS, `observations[${index}]`);
223
226
  const fields = boundedDataArray(item.fields, `observations[${index}].fields`, 0, MAX_FLIGHT_RECORDER_FIELDS)
224
227
  .map((fieldRaw, fieldIndex) => {
225
- const field = exactRecord(fieldRaw, FIELD_KEYS, `observations[${index}].fields[${fieldIndex}]`);
228
+ const field = exactRecord(fieldRaw, legacy ? PUBLIC_FIELD_KEYS : FIELD_KEYS, `observations[${index}].fields[${fieldIndex}]`);
226
229
  return Object.freeze({
227
230
  id: field.id,
228
231
  source: field.source,
229
232
  state: field.state,
230
- values: boundedDataArray(field.values, `observations[${index}].fields[${fieldIndex}].values`, 0, MAX_FLIGHT_RECORDER_INPUT_VALUES),
233
+ ...(!legacy ? { observedAt: field.observedAt } : {}),
234
+ values: boundedDataArray(field.values, `observations[${index}].fields[${fieldIndex}].values`, 0, legacy ? PUBLIC_MAX_FLIGHT_RECORDER_INPUT_VALUES : MAX_FLIGHT_RECORDER_INPUT_VALUES),
231
235
  });
232
236
  });
233
237
  return Object.freeze({
234
238
  domain: item.domain,
235
- observedAt: item.observedAt,
239
+ ...(legacy ? { observedAt: item.observedAt } : { capturedAt: item.capturedAt }),
236
240
  collectionDepth: item.collectionDepth,
237
241
  fields: Object.freeze(fields),
238
242
  });
@@ -251,7 +255,7 @@ function snapshotFlightRecorderInput(input) {
251
255
  });
252
256
  return Object.freeze({
253
257
  schema: DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA,
254
- version: DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
258
+ version: input.version,
255
259
  observations: Object.freeze(observations),
256
260
  approvedWindows: Object.freeze(approvedWindows),
257
261
  });
@@ -262,25 +266,41 @@ function text(value, label, maximum = 240) {
262
266
  .replace(/\s+/gu, ' ')
263
267
  .trim();
264
268
  }
265
- function values(value, label) {
266
- const input = boundedDataArray(value, label, 0, MAX_FLIGHT_RECORDER_INPUT_VALUES);
269
+ const RECORD_FIELDS = Object.freeze({
270
+ registry_nameservers: 'nameservers', whois_nameservers: 'nameservers', delegated_nameservers: 'nameservers',
271
+ delegation_ds: 'ds', mail_exchangers: 'mx', caa_policy: 'caa',
272
+ });
273
+ function recorderValue(value, label, legacy, field, complete = true) {
274
+ if (legacy)
275
+ return text(value, label, PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH).toLowerCase();
276
+ if (typeof value !== 'string' || !value.trim() || value.length > MAX_FLIGHT_RECORDER_VALUE_LENGTH
277
+ || /[\u0000-\u001f\u007f]/u.test(value)) {
278
+ throw new TypeError(`${label} must be bounded text without control characters.`);
279
+ }
280
+ const recordField = RECORD_FIELDS[field];
281
+ return recordField && complete ? canonicalDomainControlRecords([value], recordField)[0] : value.trim();
282
+ }
283
+ function values(value, label, legacy, field, complete) {
284
+ const input = boundedDataArray(value, label, 0, legacy ? PUBLIC_MAX_FLIGHT_RECORDER_INPUT_VALUES : MAX_FLIGHT_RECORDER_INPUT_VALUES);
285
+ const maximum = legacy ? PUBLIC_MAX_FLIGHT_RECORDER_VALUES : MAX_FLIGHT_RECORDER_VALUES;
267
286
  const output = new Set();
268
287
  for (let index = 0; index < input.length; index += 1) {
269
- const normalized = text(input[index], `${label}[${index}]`, MAX_FLIGHT_RECORDER_VALUE_LENGTH).toLowerCase();
288
+ const normalized = recorderValue(input[index], `${label}[${index}]`, legacy, field, complete);
270
289
  if (normalized)
271
290
  output.add(normalized);
272
- if (output.size > MAX_FLIGHT_RECORDER_VALUES) {
273
- throw new TypeError(`${label} must contain at most ${MAX_FLIGHT_RECORDER_VALUES} unique normalized values.`);
291
+ if (output.size > maximum) {
292
+ throw new TypeError(`${label} must contain at most ${maximum} unique normalized values.`);
274
293
  }
275
294
  }
276
295
  return [...output].sort(ordinalCompare);
277
296
  }
278
- function observation(value, index) {
279
- const item = exactRecord(value, OBSERVATION_KEYS, `observations[${index}]`);
297
+ function observation(value, index, legacy) {
298
+ const item = exactRecord(value, legacy ? PUBLIC_OBSERVATION_KEYS : OBSERVATION_KEYS, `observations[${index}]`);
299
+ const capturedAt = requireIsoTimestamp(legacy ? item.observedAt : item.capturedAt, `observations[${index}].${legacy ? 'observedAt' : 'capturedAt'}`);
280
300
  const inputFields = boundedDataArray(item.fields, `observations[${index}].fields`, 0, MAX_FLIGHT_RECORDER_FIELDS);
281
301
  const seen = new Set();
282
302
  const fields = inputFields.map((raw, fieldIndex) => {
283
- const field = exactRecord(raw, FIELD_KEYS, `observations[${index}].fields[${fieldIndex}]`);
303
+ const field = exactRecord(raw, legacy ? PUBLIC_FIELD_KEYS : FIELD_KEYS, `observations[${index}].fields[${fieldIndex}]`);
284
304
  if (typeof field.id !== 'string' || !FIELD_IDS.has(field.id) || seen.has(field.id)) {
285
305
  throw new TypeError(`observations[${index}] contains an unsupported or duplicate field.`);
286
306
  }
@@ -288,11 +308,27 @@ function observation(value, index) {
288
308
  throw new TypeError(`observations[${index}].fields[${fieldIndex}].state is unsupported.`);
289
309
  }
290
310
  seen.add(field.id);
311
+ let fieldState = field.state;
312
+ let fieldValues = values(field.values, `observations[${index}].fields[${fieldIndex}].values`, legacy, field.id, fieldState === 'observed');
313
+ const recordField = RECORD_FIELDS[field.id];
314
+ if (legacy && recordField) {
315
+ try {
316
+ fieldValues = canonicalDomainControlRecords(fieldValues, recordField);
317
+ }
318
+ catch (cause) {
319
+ if (!(cause instanceof TypeError))
320
+ throw cause;
321
+ if (fieldState === 'observed')
322
+ fieldState = 'partial';
323
+ }
324
+ }
291
325
  return Object.freeze({
292
326
  id: field.id,
293
327
  source: text(field.source, `observations[${index}].fields[${fieldIndex}].source`, MAX_FLIGHT_RECORDER_SOURCE_LENGTH),
294
- state: field.state,
295
- values: Object.freeze(values(field.values, `observations[${index}].fields[${fieldIndex}].values`)),
328
+ state: fieldState,
329
+ values: Object.freeze(fieldValues),
330
+ observedAt: legacy ? capturedAt : field.observedAt === null ? null
331
+ : requireIsoTimestamp(field.observedAt, `observations[${index}].fields[${fieldIndex}].observedAt`),
296
332
  });
297
333
  }).sort((left, right) => ordinalCompare(left.id, right.id));
298
334
  const collectionDepth = item.collectionDepth === 'deep' || item.collectionDepth === 'fast'
@@ -304,11 +340,18 @@ function observation(value, index) {
304
340
  throw new TypeError(`observations[${index}].collectionDepth is unsupported.`);
305
341
  return Object.freeze({
306
342
  domain: requireDomainName(item.domain, `observations[${index}].domain`),
307
- observedAt: requireIsoTimestamp(item.observedAt, `observations[${index}].observedAt`),
343
+ capturedAt,
308
344
  collectionDepth,
309
345
  fields: Object.freeze(fields),
310
346
  });
311
347
  }
348
+ export function normalizeDomainControlFlightRecorderObservations(value, version) {
349
+ if (!SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.some((supported) => supported === version)) {
350
+ throw new TypeError('Domain control observations use an unsupported version.');
351
+ }
352
+ return Object.freeze(boundedDataArray(value, 'observations', MIN_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_OBSERVATIONS)
353
+ .map((item, index) => observation(item, index, version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION)));
354
+ }
312
355
  function approvedWindow(value, index) {
313
356
  const item = exactRecord(value, WINDOW_KEYS, `approvedWindows[${index}]`);
314
357
  const inputFields = boundedDataArray(item.fields, `approvedWindows[${index}].fields`, 1, MAX_FLIGHT_RECORDER_FIELDS);
@@ -335,109 +378,158 @@ function approvedWindow(value, index) {
335
378
  function sameValues(left, right) {
336
379
  return left.length === right.length && left.every((value, index) => value === right[index]);
337
380
  }
338
- function matchingWindow(windows, domain, field, observedAt) {
339
- const time = Date.parse(observedAt);
381
+ function matchingWindow(windows, domain, field, after, by) {
340
382
  return windows.find((window) => (window.domain === domain
341
383
  && window.fields.includes(field)
342
- && Date.parse(window.startsAt) <= time
343
- && time <= Date.parse(window.endsAt))) ?? null;
384
+ && Date.parse(window.startsAt) <= Date.parse(after)
385
+ && Date.parse(by) <= Date.parse(window.endsAt))) ?? null;
386
+ }
387
+ /** Equal-capture disagreement stays incomplete, independently of input ordering. */
388
+ export function mergeConcurrentDomainControlFields(observations) {
389
+ return Object.freeze(DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS.flatMap((id) => {
390
+ const candidates = observations.flatMap((fields) => fields.filter((field) => field.id === id));
391
+ const first = candidates[0];
392
+ if (!first)
393
+ return [];
394
+ const identity = (field) => JSON.stringify([field.source, field.state, field.observedAt, field.values]);
395
+ if (candidates.every((field) => identity(field) === identity(first)))
396
+ return [first];
397
+ const retained = [...new Set(candidates.flatMap((field) => field.values))].sort(ordinalCompare);
398
+ const omitted = Math.max(0, retained.length - MAX_FLIGHT_RECORDER_VALUES);
399
+ const sources = new Set(candidates.map((field) => field.source));
400
+ const times = new Set(candidates.map((field) => field.observedAt));
401
+ return [Object.freeze({
402
+ id, source: `${sources.size === 1 ? first.source.slice(0, 60) : 'Multiple sources'} (conflicting observations; ${omitted} omitted)`,
403
+ state: 'partial', observedAt: times.size === 1 ? first.observedAt : null,
404
+ values: Object.freeze(retained.slice(0, MAX_FLIGHT_RECORDER_VALUES)),
405
+ })];
406
+ }));
407
+ }
408
+ function observationTimeline(observations) {
409
+ const cohorts = new Map();
410
+ for (const item of observations) {
411
+ const key = `${item.domain}\u0000${item.capturedAt}`;
412
+ const cohort = cohorts.get(key) ?? [];
413
+ cohort.push(item);
414
+ cohorts.set(key, cohort);
415
+ }
416
+ return [...cohorts.values()].map((cohort) => {
417
+ const first = cohort[0];
418
+ const collectionDepth = cohort.every((item) => item.collectionDepth === first.collectionDepth)
419
+ ? first.collectionDepth : 'unknown';
420
+ return Object.freeze({ ...first, collectionDepth, fields: mergeConcurrentDomainControlFields(cohort.map((item) => item.fields)) });
421
+ }).sort((left, right) => Date.parse(left.capturedAt) - Date.parse(right.capturedAt)
422
+ || ordinalCompare(left.domain, right.domain));
344
423
  }
345
424
  export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = new Date().toISOString()) {
346
425
  const inputRoot = exactRecord(inputRaw, ROOT_KEYS, 'Domain control flight-recorder input');
347
426
  if (inputRoot.schema !== DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA
348
- || inputRoot.version !== DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION) {
349
- throw new TypeError(`Domain control flight-recorder input must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA} version ${DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION}.`);
427
+ || !SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.some((version) => version === inputRoot.version)) {
428
+ throw new TypeError(`Domain control flight-recorder input must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA} supported version ${SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.join(' or ')}.`);
350
429
  }
351
- const input = snapshotFlightRecorderInput(inputRoot);
430
+ const input = snapshotFlightRecorderInput(inputRoot, inputRoot.version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION);
352
431
  assertPrettyJsonByteBudget(input, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_BYTES, 'Domain control flight-recorder input');
353
- const inputObservations = input.observations;
354
432
  const inputWindows = input.approvedWindows;
355
- const observations = inputObservations.map(observation)
356
- .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt)
357
- || ordinalCompare(left.domain, right.domain));
433
+ const observations = normalizeDomainControlFlightRecorderObservations(input.observations, Number(input.version));
358
434
  const windows = inputWindows.map(approvedWindow);
359
435
  if (new Set(windows.map((window) => window.id)).size !== windows.length) {
360
436
  throw new TypeError('Approved change-window ids must be unique.');
361
437
  }
362
438
  const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
363
- if (observations.some((item) => Date.parse(item.observedAt) > Date.parse(generatedAt))) {
364
- throw new TypeError('Domain control flight-recorder observations cannot be later than generatedAt.');
439
+ if (observations.some((item) => Date.parse(item.capturedAt) > Date.parse(generatedAt))) {
440
+ throw new TypeError('Domain control flight-recorder captures cannot be later than generatedAt.');
365
441
  }
366
442
  const latest = new Map();
443
+ const lastComplete = new Map();
444
+ const incompleteSince = new Map();
367
445
  const events = [];
368
- for (const item of observations) {
369
- for (const field of item.fields) {
446
+ let incompleteFields = 0;
447
+ for (const item of observationTimeline(observations)) {
448
+ const fields = [...item.fields];
449
+ for (const [key, previous] of latest) {
450
+ if (key.startsWith(`${item.domain}\u0000`) && !fields.some(field => field.id === previous.field.id)) {
451
+ fields.push({ id: previous.field.id, source: 'Not supplied in this capture', state: 'unavailable', observedAt: null, values: [] });
452
+ }
453
+ }
454
+ fields.sort((a, b) => ordinalCompare(a.id, b.id));
455
+ for (const field of fields) {
370
456
  const key = `${item.domain}\u0000${field.id}`;
371
457
  const previous = latest.get(key);
372
- latest.set(key, field);
373
- if (!previous) {
374
- if (field.state !== 'observed')
375
- continue;
376
- events.push(Object.freeze({
377
- id: `${item.domain}:${field.id}:${item.observedAt}`,
378
- domain: item.domain,
379
- field: field.id,
380
- observedAt: item.observedAt,
381
- kind: 'first_observation',
382
- state: 'observed',
383
- before: Object.freeze([]),
384
- after: field.values,
385
- source: field.source,
386
- approvedWindow: null,
387
- explanation: 'The first complete value for this source-attributed field was retained.',
388
- }));
458
+ let baseline = lastComplete.get(key);
459
+ let complete = field.state === 'observed' && field.observedAt !== null
460
+ && Date.parse(field.observedAt) <= Date.parse(item.capturedAt) && item.collectionDepth !== 'unknown';
461
+ const sameContext = previous?.field.source === field.source && previous.collectionDepth === item.collectionDepth;
462
+ const repeated = previous?.complete && complete && sameContext
463
+ && previous.field.observedAt === field.observedAt && sameValues(previous.field.values, field.values);
464
+ if (repeated)
389
465
  continue;
466
+ let limitation = field.observedAt === null ? 'The source observation time is unavailable.'
467
+ : Date.parse(field.observedAt) > Date.parse(item.capturedAt) ? 'The source observation time is later than capture.'
468
+ : item.collectionDepth === 'unknown' ? 'Collection conditions are unknown or conflicting.'
469
+ : `The source is ${field.state}.`;
470
+ const contextChanged = baseline && field.source !== 'Not supplied in this capture'
471
+ && (baseline.field.source !== field.source || (item.collectionDepth !== 'unknown' && baseline.collectionDepth !== item.collectionDepth));
472
+ if (contextChanged) {
473
+ if (complete) {
474
+ complete = false;
475
+ limitation = 'The source identity or collection conditions changed.';
476
+ }
477
+ lastComplete.delete(key);
478
+ baseline = undefined;
390
479
  }
391
- const window = matchingWindow(windows, item.domain, field.id, item.observedAt);
392
- if (previous.state === 'observed' && field.state === 'observed') {
393
- if (sameValues(previous.values, field.values))
394
- continue;
395
- events.push(Object.freeze({
396
- id: `${item.domain}:${field.id}:${item.observedAt}`,
397
- domain: item.domain,
398
- field: field.id,
399
- observedAt: item.observedAt,
400
- kind: 'observed_change',
401
- state: 'observed',
402
- before: previous.values,
403
- after: field.values,
404
- source: field.source,
405
- approvedWindow: window ? Object.freeze({ id: window.id, reason: window.reason }) : null,
406
- explanation: window
407
- ? 'A complete observed value changed during an analyst-approved change window. The evidence is retained and labelled expected rather than removed.'
408
- : 'Two complete source-attributed observations contain different values.',
409
- }));
480
+ else if (complete && baseline && Date.parse(field.observedAt) <= Date.parse(baseline.field.observedAt)) {
481
+ complete = false;
482
+ limitation = 'The source observation time did not increase.';
483
+ }
484
+ latest.set(key, { field, collectionDepth: item.collectionDepth, complete });
485
+ let kind;
486
+ let explanation;
487
+ let before = [];
488
+ let approved = null;
489
+ let changeInterval = null;
490
+ if (!complete) {
491
+ incompleteSince.set(key, (incompleteSince.get(key) ?? 0) + 1);
492
+ incompleteFields += 1;
493
+ kind = previous?.complete ? 'collection_change' : 'incomplete_observation';
494
+ before = previous?.complete ? previous.field.values : [];
495
+ explanation = `${limitation} Retained values are incomplete; this is not evidence that a prior value disappeared or changed.`;
410
496
  }
411
- else if (previous.state !== 'observed' && field.state === 'observed') {
412
- events.push(Object.freeze({
413
- id: `${item.domain}:${field.id}:${item.observedAt}`,
414
- domain: item.domain,
415
- field: field.id,
416
- observedAt: item.observedAt,
417
- kind: 'recovered',
418
- state: 'observed',
419
- before: Object.freeze([]),
420
- after: field.values,
421
- source: field.source,
422
- approvedWindow: null,
423
- explanation: 'Collection returned a complete value after an earlier incomplete source state.',
424
- }));
497
+ else if (baseline && !sameValues(baseline.field.values, field.values)) {
498
+ kind = 'observed_change';
499
+ before = baseline.field.values;
500
+ changeInterval = Object.freeze({ after: baseline.field.observedAt, by: field.observedAt, incompleteObservations: incompleteSince.get(key) ?? 0 });
501
+ approved = matchingWindow(windows, item.domain, field.id, changeInterval.after, changeInterval.by);
502
+ explanation = `Comparable complete observations contain different values. The change occurred after ${changeInterval.after} and by ${changeInterval.by}; its exact time is unknown.`
503
+ + (changeInterval.incompleteObservations ? ` ${changeInterval.incompleteObservations} incomplete observation(s) occurred in this interval.` : '')
504
+ + (approved ? ' The recorded approved window contains the entire interval.' : ' No recorded approved window contains the entire interval.');
425
505
  }
426
- else if (previous.state === 'observed' && field.state !== 'observed') {
427
- events.push(Object.freeze({
428
- id: `${item.domain}:${field.id}:${item.observedAt}`,
429
- domain: item.domain,
430
- field: field.id,
431
- observedAt: item.observedAt,
432
- kind: 'collection_change',
433
- state: 'partial',
434
- before: previous.values,
435
- after: Object.freeze([]),
436
- source: field.source,
437
- approvedWindow: null,
438
- explanation: `The source became ${field.state}; this is a collection-quality change, not evidence that the prior value disappeared.`,
439
- }));
506
+ else if (!previous) {
507
+ kind = 'first_observation';
508
+ explanation = 'The first complete source-timed value for this field was retained.';
440
509
  }
510
+ else if (!previous.complete) {
511
+ kind = 'recovered';
512
+ explanation = baseline
513
+ ? 'The recovered complete value matches the last comparable complete observation. Intervening changes during the incomplete interval remain unknown.'
514
+ : 'A complete source-timed value is available after an incomplete observation; this does not establish a value change.';
515
+ }
516
+ else {
517
+ lastComplete.set(key, { field, collectionDepth: item.collectionDepth, complete });
518
+ incompleteSince.delete(key);
519
+ continue;
520
+ }
521
+ if (complete) {
522
+ lastComplete.set(key, { field, collectionDepth: item.collectionDepth, complete });
523
+ incompleteSince.delete(key);
524
+ }
525
+ events.push(Object.freeze({
526
+ id: `${item.domain}:${field.id}:${item.capturedAt}:${events.length + 1}`,
527
+ domain: item.domain, field: field.id, observedAt: field.observedAt, capturedAt: item.capturedAt,
528
+ kind, state: complete ? 'observed' : 'partial', before: Object.freeze([...before]), after: field.values,
529
+ source: field.source, approvedWindow: approved ? Object.freeze({ id: approved.id, reason: approved.reason, startsAt: approved.startsAt, endsAt: approved.endsAt }) : null,
530
+ collectionDepth: item.collectionDepth, changeInterval,
531
+ explanation,
532
+ }));
441
533
  }
442
534
  }
443
535
  const domains = [...new Set(observations.map((item) => item.domain))].sort(ordinalCompare);
@@ -457,6 +549,7 @@ export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = ne
457
549
  unexpectedChanges: observedChanges.filter((event) => event.approvedWindow === null).length,
458
550
  collectionChanges: collectionChanges.length,
459
551
  recoveredSources: events.filter((event) => event.kind === 'recovered').length,
552
+ incompleteFields,
460
553
  }),
461
554
  limitations: DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS,
462
555
  });
@@ -475,9 +568,15 @@ function exactRecorderInteger(value, minimum, maximum, label) {
475
568
  }
476
569
  return Number(value);
477
570
  }
478
- function exactRecorderValues(value, label) {
479
- const input = boundedDataArray(value, label, 0, MAX_FLIGHT_RECORDER_VALUES);
480
- const normalized = input.map((item, index) => exactRecorderText(item, `${label}[${index}]`, MAX_FLIGHT_RECORDER_VALUE_LENGTH));
571
+ function exactRecorderValues(value, label, legacy, field, complete) {
572
+ const input = boundedDataArray(value, label, 0, legacy ? PUBLIC_MAX_FLIGHT_RECORDER_VALUES : MAX_FLIGHT_RECORDER_VALUES);
573
+ const normalized = input.map((item, index) => {
574
+ const result = legacy ? exactRecorderText(item, `${label}[${index}]`, PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH)
575
+ : recorderValue(item, `${label}[${index}]`, false, field, complete);
576
+ if (result !== item)
577
+ throw new TypeError(`${label} must use canonical values.`);
578
+ return result;
579
+ });
481
580
  if (new Set(normalized).size !== normalized.length
482
581
  || normalized.some((item, index) => index > 0 && normalized[index - 1] >= item)) {
483
582
  throw new TypeError(`${label} must contain unique canonically ordered values.`);
@@ -487,9 +586,11 @@ function exactRecorderValues(value, label) {
487
586
  export function validateDomainControlFlightRecorderDocument(value) {
488
587
  const root = exactRecord(value, DOCUMENT_ROOT_KEYS, 'Domain control flight-recorder document');
489
588
  if (root.schema !== DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA
490
- || root.version !== DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION) {
491
- throw new TypeError(`Domain control flight-recorder document must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA} version ${DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION}.`);
589
+ || !SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.some((version) => version === root.version)) {
590
+ throw new TypeError(`Domain control flight-recorder document must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA} supported version ${SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.join(' or ')}.`);
492
591
  }
592
+ const legacy = root.version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION;
593
+ const intervalVersion = root.version === DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION;
493
594
  const generatedAt = requireIsoTimestamp(root.generatedAt, 'Domain control flight-recorder document generatedAt');
494
595
  if (generatedAt !== root.generatedAt) {
495
596
  throw new TypeError('Domain control flight-recorder document generatedAt must use its canonical timestamp form.');
@@ -517,10 +618,17 @@ export function validateDomainControlFlightRecorderDocument(value) {
517
618
  collection_change: 0,
518
619
  recovered: 0,
519
620
  approved_change: 0,
621
+ incomplete_observation: 0,
520
622
  };
623
+ const seenIds = new Set();
624
+ const latest = new Map();
625
+ const lastComplete = new Map();
626
+ const incompleteSince = new Map();
627
+ let lastCapture = -Infinity;
521
628
  const events = eventInput.map((eventValue, index) => {
522
629
  const label = `Domain control flight-recorder document event ${index + 1}`;
523
- const event = exactRecord(eventValue, EVENT_KEYS, label);
630
+ const event = exactRecord(eventValue, legacy ? new Set(PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS)
631
+ : intervalVersion ? EVENT_KEYS : new Set(SOURCE_TIMED_FLIGHT_RECORDER_EVENT_KEYS), label);
524
632
  const eventDomain = requireDomainName(event.domain, `${label}.domain`);
525
633
  if (eventDomain !== event.domain || !domains.includes(eventDomain)) {
526
634
  throw new TypeError(`${label}.domain must be present in the document domain list.`);
@@ -528,52 +636,115 @@ export function validateDomainControlFlightRecorderDocument(value) {
528
636
  if (typeof event.field !== 'string' || !FIELD_IDS.has(event.field)) {
529
637
  throw new TypeError(`${label}.field is unsupported.`);
530
638
  }
531
- const observedAt = requireIsoTimestamp(event.observedAt, `${label}.observedAt`);
532
- if (observedAt !== event.observedAt || Date.parse(observedAt) > Date.parse(generatedAt)) {
533
- throw new TypeError(`${label}.observedAt must not be later than document generation.`);
534
- }
639
+ const observedAt = !legacy && event.observedAt === null ? null : requireIsoTimestamp(event.observedAt, `${label}.observedAt`);
640
+ const capturedAt = legacy ? undefined : requireIsoTimestamp(event.capturedAt, `${label}.capturedAt`);
641
+ if (observedAt !== event.observedAt || (legacy && Date.parse(observedAt) > Date.parse(generatedAt))
642
+ || (!legacy && (capturedAt !== event.capturedAt || Date.parse(capturedAt) > Date.parse(generatedAt)
643
+ || Date.parse(capturedAt) < lastCapture)))
644
+ throw new TypeError(`${label} contains an invalid or unordered timestamp.`);
645
+ if (capturedAt)
646
+ lastCapture = Date.parse(capturedAt);
535
647
  if (event.kind !== 'first_observation'
536
648
  && event.kind !== 'observed_change'
537
649
  && event.kind !== 'collection_change'
538
- && event.kind !== 'recovered') {
650
+ && event.kind !== 'recovered'
651
+ && (legacy || event.kind !== 'incomplete_observation')) {
539
652
  throw new TypeError(`${label}.kind is unsupported.`);
540
653
  }
541
- const expectedState = event.kind === 'collection_change' ? 'partial' : 'observed';
654
+ const kind = event.kind;
655
+ const expectedState = kind === 'collection_change' || kind === 'incomplete_observation' ? 'partial' : 'observed';
542
656
  if (event.state !== expectedState)
543
657
  throw new TypeError(`${label}.state is inconsistent.`);
544
- const before = exactRecorderValues(event.before, `${label}.before`);
545
- const after = exactRecorderValues(event.after, `${label}.after`);
658
+ if (!legacy && expectedState === 'observed' && (observedAt === null || Date.parse(observedAt) > Date.parse(capturedAt))) {
659
+ throw new TypeError(`${label} cannot claim a complete observation without a source time at or before capture.`);
660
+ }
661
+ const collectionDepth = intervalVersion ? event.collectionDepth : undefined;
662
+ if (intervalVersion && (collectionDepth !== 'deep' && collectionDepth !== 'fast' && collectionDepth !== 'unknown'
663
+ || expectedState === 'observed' && collectionDepth === 'unknown')) {
664
+ throw new TypeError(`${label} has invalid collection conditions.`);
665
+ }
666
+ const before = exactRecorderValues(event.before, `${label}.before`, legacy, event.field, expectedState === 'observed');
667
+ const after = exactRecorderValues(event.after, `${label}.after`, legacy, event.field, expectedState === 'observed');
546
668
  if (((event.kind === 'first_observation' || event.kind === 'recovered') && before.length)
547
- || (event.kind === 'collection_change' && after.length)) {
669
+ || (legacy && event.kind === 'collection_change' && after.length)) {
548
670
  throw new TypeError(`${label} before/after values are inconsistent.`);
549
671
  }
672
+ let changeInterval = null;
673
+ if (intervalVersion && event.changeInterval !== null) {
674
+ const interval = exactRecord(event.changeInterval, new Set(FLIGHT_RECORDER_CHANGE_INTERVAL_KEYS), `${label}.changeInterval`);
675
+ const after = requireIsoTimestamp(interval.after, `${label}.changeInterval.after`);
676
+ const by = requireIsoTimestamp(interval.by, `${label}.changeInterval.by`);
677
+ if (kind !== 'observed_change' || after !== interval.after || by !== interval.by || by !== observedAt || Date.parse(after) >= Date.parse(by)) {
678
+ throw new TypeError(`${label} has an inconsistent change interval.`);
679
+ }
680
+ changeInterval = Object.freeze({ after, by, incompleteObservations: exactRecorderInteger(interval.incompleteObservations, 0, MAX_FLIGHT_RECORDER_OBSERVATIONS, `${label}.changeInterval.incompleteObservations`) });
681
+ }
682
+ if (intervalVersion && (kind === 'observed_change') !== (changeInterval !== null))
683
+ throw new TypeError(`${label} must retain its change interval.`);
550
684
  let approvedWindow = null;
551
685
  if (event.approvedWindow !== null) {
552
686
  if (event.kind !== 'observed_change')
553
687
  throw new TypeError(`${label}.approvedWindow is inconsistent.`);
554
- const window = exactRecord(event.approvedWindow, EVENT_APPROVED_WINDOW_KEYS, `${label}.approvedWindow`);
688
+ const window = exactRecord(event.approvedWindow, intervalVersion ? EVENT_APPROVED_WINDOW_KEYS : new Set(HISTORICAL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS), `${label}.approvedWindow`);
689
+ const startsAt = intervalVersion ? requireIsoTimestamp(window.startsAt, `${label}.approvedWindow.startsAt`) : undefined;
690
+ const endsAt = intervalVersion ? requireIsoTimestamp(window.endsAt, `${label}.approvedWindow.endsAt`) : undefined;
691
+ if (intervalVersion && (!changeInterval || startsAt !== window.startsAt || endsAt !== window.endsAt
692
+ || Date.parse(startsAt) > Date.parse(changeInterval.after) || Date.parse(endsAt) < Date.parse(changeInterval.by))) {
693
+ throw new TypeError(`${label}.approvedWindow does not contain the change interval.`);
694
+ }
555
695
  approvedWindow = Object.freeze({
556
696
  id: exactRecorderText(window.id, `${label}.approvedWindow.id`, MAX_FLIGHT_RECORDER_WINDOW_ID_LENGTH),
557
697
  reason: exactRecorderText(window.reason, `${label}.approvedWindow.reason`, MAX_FLIGHT_RECORDER_WINDOW_REASON_LENGTH),
698
+ ...(intervalVersion ? { startsAt: startsAt, endsAt: endsAt } : {}),
558
699
  });
559
700
  kindCounts.approved_change += 1;
560
701
  }
561
- kindCounts[event.kind] += 1;
562
- return Object.freeze({
702
+ kindCounts[kind] += 1;
703
+ const result = Object.freeze({
563
704
  id: exactRecorderText(event.id, `${label}.id`, 400),
564
705
  domain: eventDomain,
565
706
  field: event.field,
566
707
  observedAt,
567
- kind: event.kind,
708
+ ...(capturedAt ? { capturedAt } : {}),
709
+ kind,
568
710
  state: expectedState,
569
711
  before,
570
712
  after,
571
713
  source: exactRecorderText(event.source, `${label}.source`, MAX_FLIGHT_RECORDER_SOURCE_LENGTH),
572
714
  approvedWindow,
573
- explanation: exactRecorderText(event.explanation, `${label}.explanation`, MAX_FLIGHT_RECORDER_VALUE_LENGTH),
715
+ explanation: exactRecorderText(event.explanation, `${label}.explanation`, legacy ? PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH : MAX_FLIGHT_RECORDER_VALUE_LENGTH),
716
+ ...(intervalVersion ? { collectionDepth: collectionDepth, changeInterval } : {}),
574
717
  });
718
+ if (!legacy) {
719
+ const key = `${eventDomain}\u0000${event.field}`;
720
+ const previous = intervalVersion ? lastComplete.get(key) : latest.get(key);
721
+ if (seenIds.has(result.id) || (kind === 'observed_change' && (!previous || previous.state !== 'observed'
722
+ || previous.source !== result.source || Date.parse(previous.observedAt) >= Date.parse(result.observedAt)
723
+ || !sameValues(previous.after, before) || sameValues(before, after)
724
+ || intervalVersion && (previous.collectionDepth !== result.collectionDepth || !changeInterval
725
+ || Date.parse(changeInterval.after) < Date.parse(previous.observedAt)
726
+ || changeInterval.incompleteObservations !== (incompleteSince.get(key) ?? 0))))) {
727
+ throw new TypeError(`${label} has a duplicate identity or an unsupported observed-change claim.`);
728
+ }
729
+ seenIds.add(result.id);
730
+ latest.set(key, result);
731
+ if (intervalVersion) {
732
+ if (expectedState === 'observed') {
733
+ lastComplete.set(key, result);
734
+ incompleteSince.delete(key);
735
+ }
736
+ else {
737
+ incompleteSince.set(key, (incompleteSince.get(key) ?? 0) + 1);
738
+ if (previous && result.source !== 'Not supplied in this capture'
739
+ && (previous.source !== result.source || result.collectionDepth !== 'unknown' && previous.collectionDepth !== result.collectionDepth))
740
+ lastComplete.delete(key);
741
+ }
742
+ }
743
+ }
744
+ return result;
575
745
  });
576
- const summaryInput = exactRecord(root.summary, SUMMARY_KEYS, 'Domain control flight-recorder document summary');
746
+ const summaryKeys = legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS : DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS;
747
+ const summaryInput = exactRecord(root.summary, legacy ? new Set(summaryKeys) : SUMMARY_KEYS, 'Domain control flight-recorder document summary');
577
748
  const expectedSummary = Object.freeze({
578
749
  firstObservations: kindCounts.first_observation,
579
750
  observedChanges: kindCounts.observed_change,
@@ -581,26 +752,29 @@ export function validateDomainControlFlightRecorderDocument(value) {
581
752
  unexpectedChanges: kindCounts.observed_change - kindCounts.approved_change,
582
753
  collectionChanges: kindCounts.collection_change,
583
754
  recoveredSources: kindCounts.recovered,
755
+ ...(!legacy ? { incompleteFields: kindCounts.collection_change + kindCounts.incomplete_observation } : {}),
584
756
  });
585
- for (const key of DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS) {
757
+ for (const key of summaryKeys) {
586
758
  const supplied = exactRecorderInteger(summaryInput[key], 0, MAX_FLIGHT_RECORDER_EVENTS, `Domain control flight-recorder document summary ${key}`);
587
759
  if (supplied !== expectedSummary[key]) {
588
760
  throw new TypeError('Domain control flight-recorder document summary is inconsistent.');
589
761
  }
590
762
  }
591
- const limitationsInput = boundedDataArray(root.limitations, 'Domain control flight-recorder document limitations', DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS.length, DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS.length);
592
- if (limitationsInput.some((item, index) => item !== DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS[index])) {
763
+ const limitations = legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS
764
+ : intervalVersion ? DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS : SOURCE_TIMED_FLIGHT_RECORDER_LIMITATIONS;
765
+ const limitationsInput = boundedDataArray(root.limitations, 'Domain control flight-recorder document limitations', limitations.length, limitations.length);
766
+ if (limitationsInput.some((item, index) => item !== limitations[index])) {
593
767
  throw new TypeError('Domain control flight-recorder document limitations are invalid.');
594
768
  }
595
769
  const document = Object.freeze({
596
770
  schema: DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA,
597
- version: DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
771
+ version: root.version,
598
772
  generatedAt,
599
773
  domains: Object.freeze(domains),
600
774
  observationCount,
601
775
  events: Object.freeze(events),
602
776
  summary: expectedSummary,
603
- limitations: DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS,
777
+ limitations,
604
778
  });
605
779
  assertPrettyJsonByteBudget(document, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_OUTPUT_BYTES, 'Domain control flight-recorder document');
606
780
  return document;
@@ -621,10 +795,13 @@ export function formatDomainControlFlightRecorder(document) {
621
795
  `Unexpected changes ${document.summary.unexpectedChanges}`,
622
796
  `Approved changes ${document.summary.approvedChanges}`,
623
797
  `Collection changes ${document.summary.collectionChanges}`,
798
+ ...(document.summary.incompleteFields === undefined ? [] : [`Incomplete fields ${document.summary.incompleteFields}`]),
624
799
  '',
625
800
  ];
626
801
  for (const event of document.events.filter((item) => item.kind !== 'first_observation')) {
627
- lines.push(`${event.observedAt} ${event.domain} ${event.field.replaceAll('_', ' ')} ${event.kind.replaceAll('_', ' ')}`);
802
+ lines.push(`${event.observedAt ?? 'Source time unavailable'} ${event.domain} ${event.field.replaceAll('_', ' ')} ${event.kind.replaceAll('_', ' ')}`);
803
+ if (event.capturedAt)
804
+ lines.push(` Captured ${event.capturedAt}`);
628
805
  lines.push(` ${event.explanation}`);
629
806
  }
630
807
  if (!document.events.some((item) => item.kind !== 'first_observation')) {