@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +112 -72
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. 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, 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,22 +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();
270
- if (normalized && output.size < MAX_FLIGHT_RECORDER_VALUES)
288
+ const normalized = recorderValue(input[index], `${label}[${index}]`, legacy, field, complete);
289
+ if (normalized)
271
290
  output.add(normalized);
291
+ if (output.size > maximum) {
292
+ throw new TypeError(`${label} must contain at most ${maximum} unique normalized values.`);
293
+ }
272
294
  }
273
295
  return [...output].sort(ordinalCompare);
274
296
  }
275
- function observation(value, index) {
276
- 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'}`);
277
300
  const inputFields = boundedDataArray(item.fields, `observations[${index}].fields`, 0, MAX_FLIGHT_RECORDER_FIELDS);
278
301
  const seen = new Set();
279
302
  const fields = inputFields.map((raw, fieldIndex) => {
280
- 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}]`);
281
304
  if (typeof field.id !== 'string' || !FIELD_IDS.has(field.id) || seen.has(field.id)) {
282
305
  throw new TypeError(`observations[${index}] contains an unsupported or duplicate field.`);
283
306
  }
@@ -285,11 +308,27 @@ function observation(value, index) {
285
308
  throw new TypeError(`observations[${index}].fields[${fieldIndex}].state is unsupported.`);
286
309
  }
287
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
+ }
288
325
  return Object.freeze({
289
326
  id: field.id,
290
327
  source: text(field.source, `observations[${index}].fields[${fieldIndex}].source`, MAX_FLIGHT_RECORDER_SOURCE_LENGTH),
291
- state: field.state,
292
- 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`),
293
332
  });
294
333
  }).sort((left, right) => ordinalCompare(left.id, right.id));
295
334
  const collectionDepth = item.collectionDepth === 'deep' || item.collectionDepth === 'fast'
@@ -301,11 +340,18 @@ function observation(value, index) {
301
340
  throw new TypeError(`observations[${index}].collectionDepth is unsupported.`);
302
341
  return Object.freeze({
303
342
  domain: requireDomainName(item.domain, `observations[${index}].domain`),
304
- observedAt: requireIsoTimestamp(item.observedAt, `observations[${index}].observedAt`),
343
+ capturedAt,
305
344
  collectionDepth,
306
345
  fields: Object.freeze(fields),
307
346
  });
308
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
+ }
309
355
  function approvedWindow(value, index) {
310
356
  const item = exactRecord(value, WINDOW_KEYS, `approvedWindows[${index}]`);
311
357
  const inputFields = boundedDataArray(item.fields, `approvedWindows[${index}].fields`, 1, MAX_FLIGHT_RECORDER_FIELDS);
@@ -339,102 +385,121 @@ function matchingWindow(windows, domain, field, observedAt) {
339
385
  && Date.parse(window.startsAt) <= time
340
386
  && time <= Date.parse(window.endsAt))) ?? null;
341
387
  }
388
+ /** Equal-capture disagreement stays incomplete, independently of input ordering. */
389
+ export function mergeConcurrentDomainControlFields(observations) {
390
+ return Object.freeze(DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS.flatMap((id) => {
391
+ const candidates = observations.flatMap((fields) => fields.filter((field) => field.id === id));
392
+ const first = candidates[0];
393
+ if (!first)
394
+ return [];
395
+ const identity = (field) => JSON.stringify([field.source, field.state, field.observedAt, field.values]);
396
+ if (candidates.every((field) => identity(field) === identity(first)))
397
+ return [first];
398
+ const retained = [...new Set(candidates.flatMap((field) => field.values))].sort(ordinalCompare);
399
+ const omitted = Math.max(0, retained.length - MAX_FLIGHT_RECORDER_VALUES);
400
+ const sources = new Set(candidates.map((field) => field.source));
401
+ const times = new Set(candidates.map((field) => field.observedAt));
402
+ return [Object.freeze({
403
+ id, source: `${sources.size === 1 ? first.source.slice(0, 60) : 'Multiple sources'} (conflicting observations; ${omitted} omitted)`,
404
+ state: 'partial', observedAt: times.size === 1 ? first.observedAt : null,
405
+ values: Object.freeze(retained.slice(0, MAX_FLIGHT_RECORDER_VALUES)),
406
+ })];
407
+ }));
408
+ }
409
+ function observationTimeline(observations) {
410
+ const cohorts = new Map();
411
+ for (const item of observations) {
412
+ const key = `${item.domain}\u0000${item.capturedAt}`;
413
+ const cohort = cohorts.get(key) ?? [];
414
+ cohort.push(item);
415
+ cohorts.set(key, cohort);
416
+ }
417
+ return [...cohorts.values()].map((cohort) => {
418
+ const first = cohort[0];
419
+ const collectionDepth = cohort.every((item) => item.collectionDepth === first.collectionDepth)
420
+ ? first.collectionDepth : 'unknown';
421
+ return Object.freeze({ ...first, collectionDepth, fields: mergeConcurrentDomainControlFields(cohort.map((item) => item.fields)) });
422
+ }).sort((left, right) => Date.parse(left.capturedAt) - Date.parse(right.capturedAt)
423
+ || ordinalCompare(left.domain, right.domain));
424
+ }
342
425
  export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = new Date().toISOString()) {
343
426
  const inputRoot = exactRecord(inputRaw, ROOT_KEYS, 'Domain control flight-recorder input');
344
427
  if (inputRoot.schema !== DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA
345
- || inputRoot.version !== DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION) {
346
- throw new TypeError(`Domain control flight-recorder input must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA} version ${DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION}.`);
428
+ || !SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.some((version) => version === inputRoot.version)) {
429
+ 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 ')}.`);
347
430
  }
348
- const input = snapshotFlightRecorderInput(inputRoot);
431
+ const input = snapshotFlightRecorderInput(inputRoot, inputRoot.version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION);
349
432
  assertPrettyJsonByteBudget(input, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_BYTES, 'Domain control flight-recorder input');
350
- const inputObservations = input.observations;
351
433
  const inputWindows = input.approvedWindows;
352
- const observations = inputObservations.map(observation)
353
- .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt)
354
- || ordinalCompare(left.domain, right.domain));
434
+ const observations = normalizeDomainControlFlightRecorderObservations(input.observations, Number(input.version));
355
435
  const windows = inputWindows.map(approvedWindow);
356
436
  if (new Set(windows.map((window) => window.id)).size !== windows.length) {
357
437
  throw new TypeError('Approved change-window ids must be unique.');
358
438
  }
359
439
  const generatedAt = requireIsoTimestamp(generatedAtValue, 'generatedAt');
360
- if (observations.some((item) => Date.parse(item.observedAt) > Date.parse(generatedAt))) {
361
- throw new TypeError('Domain control flight-recorder observations cannot be later than generatedAt.');
440
+ if (observations.some((item) => Date.parse(item.capturedAt) > Date.parse(generatedAt))) {
441
+ throw new TypeError('Domain control flight-recorder captures cannot be later than generatedAt.');
362
442
  }
363
443
  const latest = new Map();
364
444
  const events = [];
365
- for (const item of observations) {
445
+ let incompleteFields = 0;
446
+ for (const item of observationTimeline(observations)) {
366
447
  for (const field of item.fields) {
367
448
  const key = `${item.domain}\u0000${field.id}`;
368
449
  const previous = latest.get(key);
369
- latest.set(key, field);
370
- if (!previous) {
371
- if (field.state !== 'observed')
372
- continue;
373
- events.push(Object.freeze({
374
- id: `${item.domain}:${field.id}:${item.observedAt}`,
375
- domain: item.domain,
376
- field: field.id,
377
- observedAt: item.observedAt,
378
- kind: 'first_observation',
379
- state: 'observed',
380
- before: Object.freeze([]),
381
- after: field.values,
382
- source: field.source,
383
- approvedWindow: null,
384
- explanation: 'The first complete value for this source-attributed field was retained.',
385
- }));
450
+ let complete = field.state === 'observed' && field.observedAt !== null
451
+ && Date.parse(field.observedAt) <= Date.parse(item.capturedAt) && item.collectionDepth !== 'unknown';
452
+ const sameContext = previous?.field.source === field.source && previous.collectionDepth === item.collectionDepth;
453
+ const repeated = previous?.complete && complete && sameContext
454
+ && previous.field.observedAt === field.observedAt && sameValues(previous.field.values, field.values);
455
+ if (repeated)
386
456
  continue;
457
+ let limitation = field.observedAt === null ? 'The source observation time is unavailable.'
458
+ : Date.parse(field.observedAt) > Date.parse(item.capturedAt) ? 'The source observation time is later than capture.'
459
+ : item.collectionDepth === 'unknown' ? 'Collection conditions are unknown or conflicting.'
460
+ : `The source is ${field.state}.`;
461
+ if (complete && previous?.complete && (!sameContext
462
+ || Date.parse(field.observedAt) <= Date.parse(previous.field.observedAt))) {
463
+ complete = false;
464
+ limitation = !sameContext ? 'The source identity or collection conditions changed.'
465
+ : 'The source observation time did not increase.';
387
466
  }
388
- const window = matchingWindow(windows, item.domain, field.id, item.observedAt);
389
- if (previous.state === 'observed' && field.state === 'observed') {
390
- if (sameValues(previous.values, field.values))
391
- continue;
392
- events.push(Object.freeze({
393
- id: `${item.domain}:${field.id}:${item.observedAt}`,
394
- domain: item.domain,
395
- field: field.id,
396
- observedAt: item.observedAt,
397
- kind: 'observed_change',
398
- state: 'observed',
399
- before: previous.values,
400
- after: field.values,
401
- source: field.source,
402
- approvedWindow: window ? Object.freeze({ id: window.id, reason: window.reason }) : null,
403
- explanation: window
404
- ? 'A complete observed value changed during an analyst-approved change window. The evidence is retained and labelled expected rather than removed.'
405
- : 'Two complete source-attributed observations contain different values.',
406
- }));
467
+ latest.set(key, { field, collectionDepth: item.collectionDepth, complete });
468
+ let kind;
469
+ let explanation;
470
+ let before = [];
471
+ let approved = null;
472
+ if (!complete) {
473
+ incompleteFields += 1;
474
+ kind = previous?.complete ? 'collection_change' : 'incomplete_observation';
475
+ before = previous?.complete ? previous.field.values : [];
476
+ explanation = `${limitation} Retained values are incomplete; this is not evidence that a prior value disappeared or changed.`;
407
477
  }
408
- else if (previous.state !== 'observed' && field.state === 'observed') {
409
- events.push(Object.freeze({
410
- id: `${item.domain}:${field.id}:${item.observedAt}`,
411
- domain: item.domain,
412
- field: field.id,
413
- observedAt: item.observedAt,
414
- kind: 'recovered',
415
- state: 'observed',
416
- before: Object.freeze([]),
417
- after: field.values,
418
- source: field.source,
419
- approvedWindow: null,
420
- explanation: 'Collection returned a complete value after an earlier incomplete source state.',
421
- }));
478
+ else if (!previous) {
479
+ kind = 'first_observation';
480
+ explanation = 'The first complete source-timed value for this field was retained.';
422
481
  }
423
- else if (previous.state === 'observed' && field.state !== 'observed') {
424
- events.push(Object.freeze({
425
- id: `${item.domain}:${field.id}:${item.observedAt}`,
426
- domain: item.domain,
427
- field: field.id,
428
- observedAt: item.observedAt,
429
- kind: 'collection_change',
430
- state: 'partial',
431
- before: previous.values,
432
- after: Object.freeze([]),
433
- source: field.source,
434
- approvedWindow: null,
435
- explanation: `The source became ${field.state}; this is a collection-quality change, not evidence that the prior value disappeared.`,
436
- }));
482
+ else if (!previous.complete) {
483
+ kind = 'recovered';
484
+ explanation = 'A complete source-timed value is available after an incomplete observation; this does not establish a value change.';
437
485
  }
486
+ else {
487
+ if (sameValues(previous.field.values, field.values))
488
+ continue;
489
+ kind = 'observed_change';
490
+ before = previous.field.values;
491
+ approved = matchingWindow(windows, item.domain, field.id, field.observedAt);
492
+ explanation = approved
493
+ ? 'Two complete observations from the same source changed during the recorded change window.'
494
+ : 'Two complete observations from the same source, with increasing source times, contain different values.';
495
+ }
496
+ events.push(Object.freeze({
497
+ id: `${item.domain}:${field.id}:${item.capturedAt}:${events.length + 1}`,
498
+ domain: item.domain, field: field.id, observedAt: field.observedAt, capturedAt: item.capturedAt,
499
+ kind, state: complete ? 'observed' : 'partial', before: Object.freeze([...before]), after: field.values,
500
+ source: field.source, approvedWindow: approved ? Object.freeze({ id: approved.id, reason: approved.reason }) : null,
501
+ explanation,
502
+ }));
438
503
  }
439
504
  }
440
505
  const domains = [...new Set(observations.map((item) => item.domain))].sort(ordinalCompare);
@@ -454,6 +519,7 @@ export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = ne
454
519
  unexpectedChanges: observedChanges.filter((event) => event.approvedWindow === null).length,
455
520
  collectionChanges: collectionChanges.length,
456
521
  recoveredSources: events.filter((event) => event.kind === 'recovered').length,
522
+ incompleteFields,
457
523
  }),
458
524
  limitations: DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS,
459
525
  });
@@ -472,9 +538,15 @@ function exactRecorderInteger(value, minimum, maximum, label) {
472
538
  }
473
539
  return Number(value);
474
540
  }
475
- function exactRecorderValues(value, label) {
476
- const input = boundedDataArray(value, label, 0, MAX_FLIGHT_RECORDER_VALUES);
477
- const normalized = input.map((item, index) => exactRecorderText(item, `${label}[${index}]`, MAX_FLIGHT_RECORDER_VALUE_LENGTH));
541
+ function exactRecorderValues(value, label, legacy, field, complete) {
542
+ const input = boundedDataArray(value, label, 0, legacy ? PUBLIC_MAX_FLIGHT_RECORDER_VALUES : MAX_FLIGHT_RECORDER_VALUES);
543
+ const normalized = input.map((item, index) => {
544
+ const result = legacy ? exactRecorderText(item, `${label}[${index}]`, PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH)
545
+ : recorderValue(item, `${label}[${index}]`, false, field, complete);
546
+ if (result !== item)
547
+ throw new TypeError(`${label} must use canonical values.`);
548
+ return result;
549
+ });
478
550
  if (new Set(normalized).size !== normalized.length
479
551
  || normalized.some((item, index) => index > 0 && normalized[index - 1] >= item)) {
480
552
  throw new TypeError(`${label} must contain unique canonically ordered values.`);
@@ -484,9 +556,10 @@ function exactRecorderValues(value, label) {
484
556
  export function validateDomainControlFlightRecorderDocument(value) {
485
557
  const root = exactRecord(value, DOCUMENT_ROOT_KEYS, 'Domain control flight-recorder document');
486
558
  if (root.schema !== DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA
487
- || root.version !== DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION) {
488
- throw new TypeError(`Domain control flight-recorder document must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA} version ${DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION}.`);
559
+ || !SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.some((version) => version === root.version)) {
560
+ 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 ')}.`);
489
561
  }
562
+ const legacy = root.version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION;
490
563
  const generatedAt = requireIsoTimestamp(root.generatedAt, 'Domain control flight-recorder document generatedAt');
491
564
  if (generatedAt !== root.generatedAt) {
492
565
  throw new TypeError('Domain control flight-recorder document generatedAt must use its canonical timestamp form.');
@@ -514,10 +587,14 @@ export function validateDomainControlFlightRecorderDocument(value) {
514
587
  collection_change: 0,
515
588
  recovered: 0,
516
589
  approved_change: 0,
590
+ incomplete_observation: 0,
517
591
  };
592
+ const seenIds = new Set();
593
+ const latest = new Map();
594
+ let lastCapture = -Infinity;
518
595
  const events = eventInput.map((eventValue, index) => {
519
596
  const label = `Domain control flight-recorder document event ${index + 1}`;
520
- const event = exactRecord(eventValue, EVENT_KEYS, label);
597
+ const event = exactRecord(eventValue, legacy ? new Set(PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS) : EVENT_KEYS, label);
521
598
  const eventDomain = requireDomainName(event.domain, `${label}.domain`);
522
599
  if (eventDomain !== event.domain || !domains.includes(eventDomain)) {
523
600
  throw new TypeError(`${label}.domain must be present in the document domain list.`);
@@ -525,23 +602,32 @@ export function validateDomainControlFlightRecorderDocument(value) {
525
602
  if (typeof event.field !== 'string' || !FIELD_IDS.has(event.field)) {
526
603
  throw new TypeError(`${label}.field is unsupported.`);
527
604
  }
528
- const observedAt = requireIsoTimestamp(event.observedAt, `${label}.observedAt`);
529
- if (observedAt !== event.observedAt || Date.parse(observedAt) > Date.parse(generatedAt)) {
530
- throw new TypeError(`${label}.observedAt must not be later than document generation.`);
531
- }
605
+ const observedAt = !legacy && event.observedAt === null ? null : requireIsoTimestamp(event.observedAt, `${label}.observedAt`);
606
+ const capturedAt = legacy ? undefined : requireIsoTimestamp(event.capturedAt, `${label}.capturedAt`);
607
+ if (observedAt !== event.observedAt || (legacy && Date.parse(observedAt) > Date.parse(generatedAt))
608
+ || (!legacy && (capturedAt !== event.capturedAt || Date.parse(capturedAt) > Date.parse(generatedAt)
609
+ || Date.parse(capturedAt) < lastCapture)))
610
+ throw new TypeError(`${label} contains an invalid or unordered timestamp.`);
611
+ if (capturedAt)
612
+ lastCapture = Date.parse(capturedAt);
532
613
  if (event.kind !== 'first_observation'
533
614
  && event.kind !== 'observed_change'
534
615
  && event.kind !== 'collection_change'
535
- && event.kind !== 'recovered') {
616
+ && event.kind !== 'recovered'
617
+ && (legacy || event.kind !== 'incomplete_observation')) {
536
618
  throw new TypeError(`${label}.kind is unsupported.`);
537
619
  }
538
- const expectedState = event.kind === 'collection_change' ? 'partial' : 'observed';
620
+ const kind = event.kind;
621
+ const expectedState = kind === 'collection_change' || kind === 'incomplete_observation' ? 'partial' : 'observed';
539
622
  if (event.state !== expectedState)
540
623
  throw new TypeError(`${label}.state is inconsistent.`);
541
- const before = exactRecorderValues(event.before, `${label}.before`);
542
- const after = exactRecorderValues(event.after, `${label}.after`);
624
+ if (!legacy && expectedState === 'observed' && (observedAt === null || Date.parse(observedAt) > Date.parse(capturedAt))) {
625
+ throw new TypeError(`${label} cannot claim a complete observation without a source time at or before capture.`);
626
+ }
627
+ const before = exactRecorderValues(event.before, `${label}.before`, legacy, event.field, expectedState === 'observed');
628
+ const after = exactRecorderValues(event.after, `${label}.after`, legacy, event.field, expectedState === 'observed');
543
629
  if (((event.kind === 'first_observation' || event.kind === 'recovered') && before.length)
544
- || (event.kind === 'collection_change' && after.length)) {
630
+ || (legacy && event.kind === 'collection_change' && after.length)) {
545
631
  throw new TypeError(`${label} before/after values are inconsistent.`);
546
632
  }
547
633
  let approvedWindow = null;
@@ -555,22 +641,35 @@ export function validateDomainControlFlightRecorderDocument(value) {
555
641
  });
556
642
  kindCounts.approved_change += 1;
557
643
  }
558
- kindCounts[event.kind] += 1;
559
- return Object.freeze({
644
+ kindCounts[kind] += 1;
645
+ const result = Object.freeze({
560
646
  id: exactRecorderText(event.id, `${label}.id`, 400),
561
647
  domain: eventDomain,
562
648
  field: event.field,
563
649
  observedAt,
564
- kind: event.kind,
650
+ ...(capturedAt ? { capturedAt } : {}),
651
+ kind,
565
652
  state: expectedState,
566
653
  before,
567
654
  after,
568
655
  source: exactRecorderText(event.source, `${label}.source`, MAX_FLIGHT_RECORDER_SOURCE_LENGTH),
569
656
  approvedWindow,
570
- explanation: exactRecorderText(event.explanation, `${label}.explanation`, MAX_FLIGHT_RECORDER_VALUE_LENGTH),
657
+ explanation: exactRecorderText(event.explanation, `${label}.explanation`, legacy ? PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH : MAX_FLIGHT_RECORDER_VALUE_LENGTH),
571
658
  });
659
+ if (!legacy) {
660
+ const previous = latest.get(`${eventDomain}\u0000${event.field}`);
661
+ if (seenIds.has(result.id) || (kind === 'observed_change' && (!previous || previous.state !== 'observed'
662
+ || previous.source !== result.source || Date.parse(previous.observedAt) >= Date.parse(result.observedAt)
663
+ || !sameValues(previous.after, before) || sameValues(before, after)))) {
664
+ throw new TypeError(`${label} has a duplicate identity or an unsupported observed-change claim.`);
665
+ }
666
+ seenIds.add(result.id);
667
+ latest.set(`${eventDomain}\u0000${event.field}`, result);
668
+ }
669
+ return result;
572
670
  });
573
- const summaryInput = exactRecord(root.summary, SUMMARY_KEYS, 'Domain control flight-recorder document summary');
671
+ const summaryKeys = legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS : DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS;
672
+ const summaryInput = exactRecord(root.summary, legacy ? new Set(summaryKeys) : SUMMARY_KEYS, 'Domain control flight-recorder document summary');
574
673
  const expectedSummary = Object.freeze({
575
674
  firstObservations: kindCounts.first_observation,
576
675
  observedChanges: kindCounts.observed_change,
@@ -578,26 +677,28 @@ export function validateDomainControlFlightRecorderDocument(value) {
578
677
  unexpectedChanges: kindCounts.observed_change - kindCounts.approved_change,
579
678
  collectionChanges: kindCounts.collection_change,
580
679
  recoveredSources: kindCounts.recovered,
680
+ ...(!legacy ? { incompleteFields: kindCounts.collection_change + kindCounts.incomplete_observation } : {}),
581
681
  });
582
- for (const key of DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS) {
682
+ for (const key of summaryKeys) {
583
683
  const supplied = exactRecorderInteger(summaryInput[key], 0, MAX_FLIGHT_RECORDER_EVENTS, `Domain control flight-recorder document summary ${key}`);
584
684
  if (supplied !== expectedSummary[key]) {
585
685
  throw new TypeError('Domain control flight-recorder document summary is inconsistent.');
586
686
  }
587
687
  }
588
- const limitationsInput = boundedDataArray(root.limitations, 'Domain control flight-recorder document limitations', DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS.length, DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS.length);
589
- if (limitationsInput.some((item, index) => item !== DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS[index])) {
688
+ const limitations = legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS : DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS;
689
+ const limitationsInput = boundedDataArray(root.limitations, 'Domain control flight-recorder document limitations', limitations.length, limitations.length);
690
+ if (limitationsInput.some((item, index) => item !== limitations[index])) {
590
691
  throw new TypeError('Domain control flight-recorder document limitations are invalid.');
591
692
  }
592
693
  const document = Object.freeze({
593
694
  schema: DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA,
594
- version: DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
695
+ version: legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION : DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
595
696
  generatedAt,
596
697
  domains: Object.freeze(domains),
597
698
  observationCount,
598
699
  events: Object.freeze(events),
599
700
  summary: expectedSummary,
600
- limitations: DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS,
701
+ limitations,
601
702
  });
602
703
  assertPrettyJsonByteBudget(document, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_OUTPUT_BYTES, 'Domain control flight-recorder document');
603
704
  return document;
@@ -618,10 +719,13 @@ export function formatDomainControlFlightRecorder(document) {
618
719
  `Unexpected changes ${document.summary.unexpectedChanges}`,
619
720
  `Approved changes ${document.summary.approvedChanges}`,
620
721
  `Collection changes ${document.summary.collectionChanges}`,
722
+ ...(document.summary.incompleteFields === undefined ? [] : [`Incomplete fields ${document.summary.incompleteFields}`]),
621
723
  '',
622
724
  ];
623
725
  for (const event of document.events.filter((item) => item.kind !== 'first_observation')) {
624
- lines.push(`${event.observedAt} ${event.domain} ${event.field.replaceAll('_', ' ')} ${event.kind.replaceAll('_', ' ')}`);
726
+ lines.push(`${event.observedAt ?? 'Source time unavailable'} ${event.domain} ${event.field.replaceAll('_', ' ')} ${event.kind.replaceAll('_', ' ')}`);
727
+ if (event.capturedAt)
728
+ lines.push(` Captured ${event.capturedAt}`);
625
729
  lines.push(` ${event.explanation}`);
626
730
  }
627
731
  if (!document.events.some((item) => item.kind !== 'first_observation')) {