@slicedearth/whoisleuth-cli 2.3.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 (264) hide show
  1. package/NOTICE +6 -0
  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/arguments.mjs +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  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 +83 -61
  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-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. 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,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);
@@ -342,102 +385,121 @@ function matchingWindow(windows, domain, field, observedAt) {
342
385
  && Date.parse(window.startsAt) <= time
343
386
  && time <= Date.parse(window.endsAt))) ?? null;
344
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
+ }
345
425
  export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = new Date().toISOString()) {
346
426
  const inputRoot = exactRecord(inputRaw, ROOT_KEYS, 'Domain control flight-recorder input');
347
427
  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}.`);
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 ')}.`);
350
430
  }
351
- const input = snapshotFlightRecorderInput(inputRoot);
431
+ const input = snapshotFlightRecorderInput(inputRoot, inputRoot.version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION);
352
432
  assertPrettyJsonByteBudget(input, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_BYTES, 'Domain control flight-recorder input');
353
- const inputObservations = input.observations;
354
433
  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));
434
+ const observations = normalizeDomainControlFlightRecorderObservations(input.observations, Number(input.version));
358
435
  const windows = inputWindows.map(approvedWindow);
359
436
  if (new Set(windows.map((window) => window.id)).size !== windows.length) {
360
437
  throw new TypeError('Approved change-window ids must be unique.');
361
438
  }
362
439
  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.');
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.');
365
442
  }
366
443
  const latest = new Map();
367
444
  const events = [];
368
- for (const item of observations) {
445
+ let incompleteFields = 0;
446
+ for (const item of observationTimeline(observations)) {
369
447
  for (const field of item.fields) {
370
448
  const key = `${item.domain}\u0000${field.id}`;
371
449
  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
- }));
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)
389
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.';
390
466
  }
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
- }));
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.`;
410
477
  }
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
- }));
478
+ else if (!previous) {
479
+ kind = 'first_observation';
480
+ explanation = 'The first complete source-timed value for this field was retained.';
425
481
  }
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
- }));
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.';
440
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
+ }));
441
503
  }
442
504
  }
443
505
  const domains = [...new Set(observations.map((item) => item.domain))].sort(ordinalCompare);
@@ -457,6 +519,7 @@ export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = ne
457
519
  unexpectedChanges: observedChanges.filter((event) => event.approvedWindow === null).length,
458
520
  collectionChanges: collectionChanges.length,
459
521
  recoveredSources: events.filter((event) => event.kind === 'recovered').length,
522
+ incompleteFields,
460
523
  }),
461
524
  limitations: DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS,
462
525
  });
@@ -475,9 +538,15 @@ function exactRecorderInteger(value, minimum, maximum, label) {
475
538
  }
476
539
  return Number(value);
477
540
  }
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));
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
+ });
481
550
  if (new Set(normalized).size !== normalized.length
482
551
  || normalized.some((item, index) => index > 0 && normalized[index - 1] >= item)) {
483
552
  throw new TypeError(`${label} must contain unique canonically ordered values.`);
@@ -487,9 +556,10 @@ function exactRecorderValues(value, label) {
487
556
  export function validateDomainControlFlightRecorderDocument(value) {
488
557
  const root = exactRecord(value, DOCUMENT_ROOT_KEYS, 'Domain control flight-recorder document');
489
558
  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}.`);
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 ')}.`);
492
561
  }
562
+ const legacy = root.version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION;
493
563
  const generatedAt = requireIsoTimestamp(root.generatedAt, 'Domain control flight-recorder document generatedAt');
494
564
  if (generatedAt !== root.generatedAt) {
495
565
  throw new TypeError('Domain control flight-recorder document generatedAt must use its canonical timestamp form.');
@@ -517,10 +587,14 @@ export function validateDomainControlFlightRecorderDocument(value) {
517
587
  collection_change: 0,
518
588
  recovered: 0,
519
589
  approved_change: 0,
590
+ incomplete_observation: 0,
520
591
  };
592
+ const seenIds = new Set();
593
+ const latest = new Map();
594
+ let lastCapture = -Infinity;
521
595
  const events = eventInput.map((eventValue, index) => {
522
596
  const label = `Domain control flight-recorder document event ${index + 1}`;
523
- 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);
524
598
  const eventDomain = requireDomainName(event.domain, `${label}.domain`);
525
599
  if (eventDomain !== event.domain || !domains.includes(eventDomain)) {
526
600
  throw new TypeError(`${label}.domain must be present in the document domain list.`);
@@ -528,23 +602,32 @@ export function validateDomainControlFlightRecorderDocument(value) {
528
602
  if (typeof event.field !== 'string' || !FIELD_IDS.has(event.field)) {
529
603
  throw new TypeError(`${label}.field is unsupported.`);
530
604
  }
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
- }
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);
535
613
  if (event.kind !== 'first_observation'
536
614
  && event.kind !== 'observed_change'
537
615
  && event.kind !== 'collection_change'
538
- && event.kind !== 'recovered') {
616
+ && event.kind !== 'recovered'
617
+ && (legacy || event.kind !== 'incomplete_observation')) {
539
618
  throw new TypeError(`${label}.kind is unsupported.`);
540
619
  }
541
- const expectedState = event.kind === 'collection_change' ? 'partial' : 'observed';
620
+ const kind = event.kind;
621
+ const expectedState = kind === 'collection_change' || kind === 'incomplete_observation' ? 'partial' : 'observed';
542
622
  if (event.state !== expectedState)
543
623
  throw new TypeError(`${label}.state is inconsistent.`);
544
- const before = exactRecorderValues(event.before, `${label}.before`);
545
- 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');
546
629
  if (((event.kind === 'first_observation' || event.kind === 'recovered') && before.length)
547
- || (event.kind === 'collection_change' && after.length)) {
630
+ || (legacy && event.kind === 'collection_change' && after.length)) {
548
631
  throw new TypeError(`${label} before/after values are inconsistent.`);
549
632
  }
550
633
  let approvedWindow = null;
@@ -558,22 +641,35 @@ export function validateDomainControlFlightRecorderDocument(value) {
558
641
  });
559
642
  kindCounts.approved_change += 1;
560
643
  }
561
- kindCounts[event.kind] += 1;
562
- return Object.freeze({
644
+ kindCounts[kind] += 1;
645
+ const result = Object.freeze({
563
646
  id: exactRecorderText(event.id, `${label}.id`, 400),
564
647
  domain: eventDomain,
565
648
  field: event.field,
566
649
  observedAt,
567
- kind: event.kind,
650
+ ...(capturedAt ? { capturedAt } : {}),
651
+ kind,
568
652
  state: expectedState,
569
653
  before,
570
654
  after,
571
655
  source: exactRecorderText(event.source, `${label}.source`, MAX_FLIGHT_RECORDER_SOURCE_LENGTH),
572
656
  approvedWindow,
573
- 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),
574
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;
575
670
  });
576
- 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');
577
673
  const expectedSummary = Object.freeze({
578
674
  firstObservations: kindCounts.first_observation,
579
675
  observedChanges: kindCounts.observed_change,
@@ -581,26 +677,28 @@ export function validateDomainControlFlightRecorderDocument(value) {
581
677
  unexpectedChanges: kindCounts.observed_change - kindCounts.approved_change,
582
678
  collectionChanges: kindCounts.collection_change,
583
679
  recoveredSources: kindCounts.recovered,
680
+ ...(!legacy ? { incompleteFields: kindCounts.collection_change + kindCounts.incomplete_observation } : {}),
584
681
  });
585
- for (const key of DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS) {
682
+ for (const key of summaryKeys) {
586
683
  const supplied = exactRecorderInteger(summaryInput[key], 0, MAX_FLIGHT_RECORDER_EVENTS, `Domain control flight-recorder document summary ${key}`);
587
684
  if (supplied !== expectedSummary[key]) {
588
685
  throw new TypeError('Domain control flight-recorder document summary is inconsistent.');
589
686
  }
590
687
  }
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])) {
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])) {
593
691
  throw new TypeError('Domain control flight-recorder document limitations are invalid.');
594
692
  }
595
693
  const document = Object.freeze({
596
694
  schema: DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA,
597
- version: DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
695
+ version: legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION : DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
598
696
  generatedAt,
599
697
  domains: Object.freeze(domains),
600
698
  observationCount,
601
699
  events: Object.freeze(events),
602
700
  summary: expectedSummary,
603
- limitations: DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS,
701
+ limitations,
604
702
  });
605
703
  assertPrettyJsonByteBudget(document, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_OUTPUT_BYTES, 'Domain control flight-recorder document');
606
704
  return document;
@@ -621,10 +719,13 @@ export function formatDomainControlFlightRecorder(document) {
621
719
  `Unexpected changes ${document.summary.unexpectedChanges}`,
622
720
  `Approved changes ${document.summary.approvedChanges}`,
623
721
  `Collection changes ${document.summary.collectionChanges}`,
722
+ ...(document.summary.incompleteFields === undefined ? [] : [`Incomplete fields ${document.summary.incompleteFields}`]),
624
723
  '',
625
724
  ];
626
725
  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('_', ' ')}`);
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}`);
628
729
  lines.push(` ${event.explanation}`);
629
730
  }
630
731
  if (!document.events.some((item) => item.kind !== 'first_observation')) {