@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/NOTICE +2 -2
  2. package/cli/argument-values.mjs +55 -0
  3. package/cli/arguments.mjs +24 -470
  4. package/cli/artifact-validation/case-response.mjs +21 -6
  5. package/cli/artifact-validation/lookup-evidence.mjs +3 -3
  6. package/cli/artifact-validation/signed-review.mjs +59 -13
  7. package/cli/artifact-verify.mjs +2 -0
  8. package/cli/assurance-arguments.mjs +56 -0
  9. package/cli/assurance-command-definitions.mjs +179 -0
  10. package/cli/assurance-command-runner.mjs +1 -1
  11. package/cli/bounded-json.mjs +1 -1
  12. package/cli/bulk-checkpoint.mjs +1 -1
  13. package/cli/bulk-command-runner.mjs +2 -2
  14. package/cli/bulk.mjs +2 -3
  15. package/cli/case-command.mjs +20 -3
  16. package/cli/case-pack.mjs +12 -2
  17. package/cli/ci-report.mjs +6 -3
  18. package/cli/collection-arguments.mjs +123 -0
  19. package/cli/collection-command-definitions.mjs +330 -0
  20. package/cli/collection-preflight.mjs +8 -1
  21. package/cli/command-argument-grammar.mjs +8 -210
  22. package/cli/command-definition.mjs +329 -0
  23. package/cli/command-reference.mjs +55 -1388
  24. package/cli/command-seeds.mjs +18 -0
  25. package/cli/compare.mjs +1 -1
  26. package/cli/config-profile.mjs +1 -1
  27. package/cli/ct-event-intake.mjs +4 -4
  28. package/cli/discover.mjs +2 -2
  29. package/cli/discovery-observation-snapshot.mjs +2 -2
  30. package/cli/discovery-scan-command-runner.mjs +3 -3
  31. package/cli/discovery-scan.mjs +1 -1
  32. package/cli/discovery-snapshot.mjs +2 -2
  33. package/cli/discovery-workflow.mjs +4 -4
  34. package/cli/domain-control-monitor.mjs +6 -6
  35. package/cli/domain-control-observations.mjs +2 -1
  36. package/cli/errors.mjs +5 -6
  37. package/cli/evidence-arguments.mjs +46 -0
  38. package/cli/evidence-command-definitions.mjs +69 -0
  39. package/cli/evidence-command-runner.mjs +2 -3
  40. package/cli/evidence-signing.mjs +3 -2
  41. package/cli/external-observation-mapping.mjs +2 -2
  42. package/cli/formatters/terminal-command-formats.mjs +6 -0
  43. package/cli/formatters/terminal-lookup.mjs +6 -0
  44. package/cli/history-arguments.mjs +39 -0
  45. package/cli/history-command-definitions.mjs +88 -0
  46. package/cli/indicator-set-command.mjs +33 -0
  47. package/cli/inline-command-families.mjs +6 -47
  48. package/cli/inline-command-runner.mjs +4 -31
  49. package/cli/input.mjs +2 -2
  50. package/cli/intake-command.mjs +107 -0
  51. package/cli/interchange-report.mjs +4 -2
  52. package/cli/investigation-artifacts.mjs +1 -1
  53. package/cli/investigation-plan.mjs +32 -154
  54. package/cli/investigation-recipes.mjs +177 -0
  55. package/cli/investigation-run.mjs +78 -12
  56. package/cli/local-mmdb-review.mjs +197 -17
  57. package/cli/lookalike-calibration.mjs +1 -1
  58. package/cli/lookup-brief.mjs +2 -2
  59. package/cli/lookup-command-runner.mjs +18 -35
  60. package/cli/lookup-diff.mjs +10 -9
  61. package/cli/lookup-plan.mjs +10 -14
  62. package/cli/lookup-reconcile.mjs +6 -5
  63. package/cli/lookup-timeline.mjs +7 -6
  64. package/cli/mail-header-review.mjs +24 -247
  65. package/cli/mail-review.mjs +9 -7
  66. package/cli/network-arguments.mjs +51 -0
  67. package/cli/network-command-definitions.mjs +130 -0
  68. package/cli/network-command-runner.mjs +43 -31
  69. package/cli/offline-evidence-review.mjs +39 -6
  70. package/cli/posture.mjs +1 -1
  71. package/cli/registry-fixture-scaffold.mjs +1 -1
  72. package/cli/retained-artifact-diff.mjs +8 -7
  73. package/cli/review-arguments.mjs +131 -0
  74. package/cli/review-command-definitions.mjs +273 -0
  75. package/cli/review-command-runner.mjs +68 -158
  76. package/cli/risk-calibration.mjs +8 -8
  77. package/cli/runner.mjs +20 -32
  78. package/cli/saved-lookup.mjs +2 -2
  79. package/cli/selected-input-worker.mjs +63 -0
  80. package/cli/sharing-review.mjs +5 -64
  81. package/cli/source-reliability.mjs +2 -2
  82. package/cli/support-arguments.mjs +49 -0
  83. package/cli/support-command-definitions.mjs +209 -0
  84. package/cli/support-command-runner.mjs +2 -2
  85. package/cli/workflow-arguments.mjs +86 -0
  86. package/cli/workflow-command-definitions.mjs +78 -0
  87. package/cli/workflow-command-runner.mjs +8 -1
  88. package/docs/cli-reference.md +27 -0
  89. package/docs/cli.md +223 -2
  90. package/docs/contextual-reviews.md +155 -0
  91. package/lib/analyst-taxonomy.mjs +2 -40
  92. package/lib/application-version.mjs +1 -1
  93. package/lib/availability.mjs +13 -789
  94. package/lib/bounded-contract-normalizers.mjs +4 -0
  95. package/lib/bounded-json.mjs +2 -330
  96. package/lib/browser-library-profile.mjs +62 -84
  97. package/lib/candidate-provenance-bounds.mjs +2 -0
  98. package/lib/classify.mjs +1 -1
  99. package/lib/ct-query.mjs +2 -27
  100. package/lib/ct-response-bounds.mjs +2 -9
  101. package/lib/ct-search.mjs +83 -47
  102. package/lib/dmarc-discovery.mjs +62 -0
  103. package/lib/dns-convergence-review.mjs +21 -8
  104. package/lib/dns-delegation-health.mjs +12 -8
  105. package/lib/dns-inheritance-review.mjs +53 -63
  106. package/lib/dns-intelligence.mjs +9 -7
  107. package/lib/dns-query-coverage.mjs +40 -0
  108. package/lib/domain-assurance.mjs +1 -1
  109. package/lib/domain-change-packet.mjs +12 -13
  110. package/lib/domain-change-review.mjs +32 -14
  111. package/lib/domain-control-flight-recorder.mjs +103 -27
  112. package/lib/domain-control-manifest.mjs +25 -17
  113. package/lib/domain-posture-analysis.mjs +22 -5
  114. package/lib/domain-posture.mjs +16 -15
  115. package/lib/domain-registration.mjs +392 -0
  116. package/lib/evidence-export-privacy.mjs +22 -15
  117. package/lib/evidence-export.mjs +3 -3
  118. package/lib/evidence-report.mjs +4 -1
  119. package/lib/external-intelligence-risk.mjs +2 -124
  120. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  121. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  122. package/lib/generated/retire-browser-catalog.mjs +332 -19
  123. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  124. package/lib/html-signals.mjs +7 -2
  125. package/lib/http-evidence-bounds.mjs +2 -8
  126. package/lib/http-intelligence.mjs +13 -1
  127. package/lib/idn-confusable-policy.mjs +6 -4
  128. package/lib/idn-confusables.mjs +1 -1
  129. package/lib/interchange-fidelity-registry.mjs +10 -0
  130. package/lib/lookup-contract-primitives.mjs +1 -1
  131. package/lib/lookup-diagnostics.mjs +1 -23
  132. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  133. package/lib/lookup-progress.mjs +50 -1
  134. package/lib/lookup-response-contract.mjs +69 -40
  135. package/lib/lookup-response.mjs +5 -15
  136. package/lib/lookup-source-progress.mjs +8 -22
  137. package/lib/lookup.mjs +1 -1
  138. package/lib/opportunity-scoring.mjs +2 -107
  139. package/lib/outbound-identity.mjs +1 -1
  140. package/lib/page-fingerprints.mjs +18 -1
  141. package/lib/perceptual-hash-comparison.mjs +2 -34
  142. package/lib/perceptual-hash.mjs +36 -10
  143. package/lib/portable-generator.mjs +2 -29
  144. package/lib/project-metadata.mjs +2 -8
  145. package/lib/rdap-client.mjs +11 -7
  146. package/lib/registrable-domain.mjs +2 -56
  147. package/lib/registrar-standing-contract.mjs +1 -1
  148. package/lib/registrar-standing.mjs +9 -13
  149. package/lib/registry-capabilities.mjs +2 -2
  150. package/lib/registry-capability-catalogue.mjs +27 -19
  151. package/lib/risk-calibration-summary.mjs +2 -352
  152. package/lib/risk-scoring.mjs +2 -345
  153. package/lib/scoring-evidence-quality.mjs +2 -107
  154. package/lib/semantic-version.mjs +2 -54
  155. package/lib/service-binding-dns.mjs +27 -0
  156. package/lib/smtp-transport-review.mjs +48 -19
  157. package/lib/sslbl-certificates.generated.mjs +141 -139
  158. package/lib/static-html-analysis.mjs +1 -1
  159. package/lib/structured-data-identity.mjs +1 -1
  160. package/lib/threat-intelligence-manifests.mjs +1 -1
  161. package/lib/threat-intelligence-runtime.mjs +1 -1
  162. package/lib/threat-intelligence-targets.mjs +1 -1
  163. package/lib/threat-intelligence-types.mjs +2 -75
  164. package/lib/typosquat-generator.mjs +11 -2
  165. package/lib/website-enrichment.mjs +388 -0
  166. package/lib/whois-authority.mjs +43 -5
  167. package/lib/whois-normalization.mjs +3 -1
  168. package/lib/whois-parser.mjs +5 -3
  169. package/package.json +13 -14
  170. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  171. package/packages/analysis/bounded-json.mjs +330 -0
  172. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  173. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  174. package/packages/analysis/ct-query.mjs +27 -0
  175. package/packages/analysis/ct-response-bounds.mjs +9 -0
  176. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  177. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  178. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  179. package/packages/analysis/opportunity-scoring.mjs +107 -0
  180. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  181. package/packages/analysis/portable-generator.mjs +29 -0
  182. package/packages/analysis/project-metadata.mjs +8 -0
  183. package/packages/analysis/registrable-domain.mjs +56 -0
  184. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  185. package/packages/analysis/risk-scoring.mjs +345 -0
  186. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  187. package/packages/analysis/semantic-version.mjs +54 -0
  188. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  189. package/packages/cases/case-evidence-links.mjs +135 -0
  190. package/packages/cases/case-evidence-model.mjs +78 -12
  191. package/packages/cases/case-evidence-quality.mjs +5 -0
  192. package/packages/cases/case-export-input.mjs +7 -5
  193. package/packages/cases/case-incident-context.mjs +52 -0
  194. package/packages/cases/case-markdown.mjs +11 -0
  195. package/packages/cases/case-migration-model.mjs +40 -24
  196. package/packages/cases/case-recheck-model.mjs +90 -32
  197. package/packages/cases/case-record-contracts.mjs +1 -1
  198. package/packages/cases/case-record-core.mjs +1 -1
  199. package/packages/cases/case-record-operations.mjs +40 -112
  200. package/packages/cases/case-record-projection.mjs +8 -1
  201. package/packages/cases/case-record-version-input.mjs +19 -0
  202. package/packages/cases/case-report.mjs +63 -27
  203. package/packages/cases/case-requested-evidence.mjs +146 -0
  204. package/packages/cases/case-response-actions.mjs +103 -1
  205. package/packages/cases/case-response-model.mjs +9 -1
  206. package/packages/cases/case-response-outcomes.mjs +29 -8
  207. package/packages/cases/case-response-packet-render.mjs +13 -9
  208. package/packages/cases/case-response-packet.mjs +25 -11
  209. package/packages/cases/case-response-records.mjs +0 -1
  210. package/packages/cases/case-response-review-inputs.mjs +14 -3
  211. package/packages/cases/case-storage-model.mjs +1 -1
  212. package/packages/cases/case-workflow-metadata.mjs +225 -0
  213. package/packages/cases/case-workflow-migration.mjs +43 -0
  214. package/packages/cases/http-summary.mjs +6 -1
  215. package/packages/comparison/favicon-similarity.mjs +1 -1
  216. package/packages/comparison/page-similarity.mjs +1 -1
  217. package/packages/comparison/relationship-evidence.mjs +21 -9
  218. package/packages/comparison/relationship-provenance.mjs +7 -1
  219. package/packages/contracts/analyst-interchange.mjs +28 -10
  220. package/packages/contracts/case-portability.mjs +118 -25
  221. package/packages/contracts/cli-command-semantics.mjs +2 -0
  222. package/packages/contracts/cli-grammar.mjs +6 -0
  223. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  224. package/packages/contracts/context-review.mjs +24 -0
  225. package/packages/contracts/document-intake.mjs +11 -0
  226. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  227. package/packages/contracts/domain-control-manifest.mjs +1 -1
  228. package/packages/contracts/domain-control-monitor.mjs +13 -2
  229. package/packages/contracts/domain-control-review.mjs +36 -15
  230. package/packages/contracts/har-review.mjs +2 -0
  231. package/packages/contracts/identity-events.mjs +4 -0
  232. package/packages/contracts/investigation-run.mjs +2 -0
  233. package/packages/contracts/ip-address.mjs +10 -0
  234. package/packages/contracts/mail-authentication.mjs +5 -0
  235. package/packages/contracts/message-intake.mjs +31 -0
  236. package/packages/contracts/offline-comparison.mjs +4 -1
  237. package/packages/contracts/passive-mail.mjs +10 -0
  238. package/packages/contracts/web-capture.mjs +2 -2
  239. package/packages/contracts/workspace-portability.mjs +63 -17
  240. package/packages/evidence/collection-quality.mjs +67 -0
  241. package/packages/evidence/dns-query-outcome.mjs +72 -0
  242. package/packages/evidence/email-recipient.mjs +18 -0
  243. package/packages/evidence/lookup-target.mjs +5 -1
  244. package/packages/evidence/observation.mjs +2 -0
  245. package/packages/evidence/png-pixels.mjs +117 -0
  246. package/packages/evidence/public-address-policy.mjs +83 -0
  247. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  248. package/packages/interchange/external-findings-import.mjs +4 -12
  249. package/packages/interchange/managed-indicator-export.mjs +35 -0
  250. package/packages/interchange/managed-indicator-set.mjs +210 -0
  251. package/packages/interchange/misp-indicator-export.mjs +93 -0
  252. package/packages/interchange/sharing-policy.mjs +56 -0
  253. package/packages/interchange/stix-indicator-export.mjs +129 -0
  254. package/packages/interchange/web-capture-import.mjs +22 -15
  255. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  256. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  257. package/packages/investigation/capture-coverage.mjs +57 -0
  258. package/packages/investigation/connector-provenance-review.mjs +100 -0
  259. package/packages/investigation/context-review.mjs +35 -0
  260. package/packages/investigation/document-intake.mjs +81 -0
  261. package/packages/investigation/docx-intake.mjs +133 -0
  262. package/packages/investigation/domain-history-review.mjs +100 -0
  263. package/packages/investigation/domain-transition-review.mjs +47 -0
  264. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  265. package/packages/investigation/har-intake.mjs +68 -0
  266. package/packages/investigation/identity-event-intake.mjs +131 -0
  267. package/packages/investigation/identity-incident-review.mjs +39 -0
  268. package/packages/investigation/incident-sequence-review.mjs +58 -0
  269. package/packages/investigation/intake-report.mjs +19 -0
  270. package/packages/investigation/investigation-manifest.mjs +2 -2
  271. package/packages/investigation/link-intake.mjs +179 -0
  272. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  273. package/packages/investigation/mail-authentication-review.mjs +222 -0
  274. package/packages/investigation/mail-header-identity.mjs +209 -0
  275. package/packages/investigation/message-intake.mjs +195 -0
  276. package/packages/investigation/page-behaviour.mjs +113 -0
  277. package/packages/investigation/pdf-intake.mjs +155 -0
  278. package/packages/investigation/platform-continuity-review.mjs +60 -0
  279. package/packages/investigation/qr-intake.mjs +56 -0
  280. package/packages/investigation/requested-action-hints.mjs +11 -0
  281. package/packages/investigation/selected-input-review.mjs +17 -0
  282. package/packages/investigation/storefront-review.mjs +70 -0
  283. package/packages/workspace/brand-profile-model.mjs +2 -2
  284. package/packages/workspace/bulk-review-model.mjs +28 -26
  285. package/packages/workspace/bulk-session-model.mjs +23 -13
  286. package/packages/workspace/campaign-model.mjs +1 -1
  287. package/packages/workspace/detection-rule-model.mjs +1 -1
  288. package/packages/workspace/hostile-input.mjs +1 -1
  289. package/packages/workspace/investigation-guide.mjs +2 -2
  290. package/packages/workspace/investigation-template-model.mjs +26 -2
  291. package/packages/workspace/page-baseline.mjs +2 -2
  292. package/packages/workspace/relationship-observation-model.mjs +17 -13
  293. package/packages/workspace/shortlist-model.mjs +14 -5
  294. package/packages/workspace/watchlist-history.mjs +28 -12
  295. package/packages/workspace/watchlist-store.mjs +17 -5
  296. package/packages/workspace/website-snapshot-model.mjs +28 -3
  297. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  298. package/packages/workspace/workspace-archive.mjs +5 -4
  299. package/third-party-notices.txt +549 -16
  300. package/lib/hostname.mjs +0 -2
@@ -0,0 +1,40 @@
1
+ import { exactKeys, requireRecord } from "./bounded-contract-normalizers.mjs";
2
+ const QUERY_KEYS = new Set(['owner', 'type', 'state']);
3
+ /** A positive legacy record proves its own query scope, never another type's absence. */
4
+ export function normaliseDnsQueryCoverage(value, options) {
5
+ const { label, maximum, state, records } = options;
6
+ if (options.legacy) {
7
+ return Object.freeze([...new Map(records.map((record) => [
8
+ `${record.owner}\u0000${record.type}`,
9
+ Object.freeze({ owner: record.owner, type: record.type, state }),
10
+ ])).values()]);
11
+ }
12
+ if (!Array.isArray(value) || value.length > maximum)
13
+ throw new TypeError(`${label} must contain no more than ${maximum} query outcomes.`);
14
+ const byKey = new Map();
15
+ for (const [index, raw] of value.entries()) {
16
+ const path = `${label}[${index}]`;
17
+ const item = requireRecord(raw, path);
18
+ exactKeys(item, QUERY_KEYS, path);
19
+ const owner = options.owner(item.owner, `${path}.owner`);
20
+ const type = options.type(item.type, `${path}.type`);
21
+ if (item.state !== 'observed' && item.state !== 'partial' && item.state !== 'unavailable')
22
+ throw new TypeError(`${path}.state is unsupported.`);
23
+ if ((state === 'unavailable' && item.state !== 'unavailable') || (state === 'observed' && item.state !== 'observed')) {
24
+ throw new TypeError(`${path}.state contradicts the snapshot state.`);
25
+ }
26
+ const key = `${owner}\u0000${type}`;
27
+ if (byKey.has(key))
28
+ throw new TypeError(`${path} duplicates an owner and type.`);
29
+ byKey.set(key, Object.freeze({ owner, type, state: item.state }));
30
+ }
31
+ for (const record of records) {
32
+ const query = byKey.get(`${record.owner}\u0000${record.type}`);
33
+ if (!query || query.state === 'unavailable')
34
+ throw new TypeError(`${label} must declare an observed or partial query for every supplied record.`);
35
+ }
36
+ return Object.freeze([...byKey.values()]);
37
+ }
38
+ export function dnsQueryState(queries, owner, type) {
39
+ return queries.find((query) => query.owner === owner && query.type === type)?.state ?? 'not_queried';
40
+ }
@@ -243,7 +243,7 @@ function buildRecoveryDependencies(input) {
243
243
  };
244
244
  }
245
245
  const RETIREMENT_CHECKS = Object.freeze([
246
- ['autoRenewDisabled', 'Auto-renew is intentionally configured', true],
246
+ ['autoRenewDisabled', 'Auto-renew is disabled as declared', true],
247
247
  ['registrarLockMaintained', 'Registrar lock remains enabled', true],
248
248
  ['websiteRetired', 'Web service is retired or intentionally redirected', true],
249
249
  ['mailRetired', 'Mail service is retired', true],
@@ -5,9 +5,10 @@ import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../packages/evidence/art
5
5
  export const DOMAIN_CHANGE_PACKET_INPUT_SCHEMA = 'whoisleuth.domain-change-packet.input';
6
6
  export const DOMAIN_CHANGE_PACKET_SCHEMA = 'whoisleuth.domain-change-packet';
7
7
  export const DOMAIN_CHANGE_PACKET_INPUT_VERSION = 1;
8
- export const DOMAIN_CHANGE_PACKET_VERSION = 3;
8
+ export const DOMAIN_CHANGE_PACKET_VERSION = 4;
9
9
  export const DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS = Object.freeze({
10
10
  2: 1,
11
+ 3: 2,
11
12
  [DOMAIN_CHANGE_PACKET_VERSION]: DOMAIN_CHANGE_REVIEW_VERSION,
12
13
  });
13
14
  export const SUPPORTED_DOMAIN_CHANGE_PACKET_VERSIONS = Object.freeze(Object.keys(DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS).map(Number));
@@ -43,16 +44,10 @@ function changeSummary(before, after) {
43
44
  const beforeRows = rows(before);
44
45
  const afterRows = rows(after);
45
46
  const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
46
- const authorityCollectionComplete = (review) => {
47
- const representative = review.authoritativeRecordMatrix[0];
48
- return Boolean(representative
49
- && representative.observations.length >= 2
50
- && representative.observations.every((item) => item.state === 'observed'));
51
- };
52
- const rowEvidence = (review, row) => {
47
+ const rowEvidence = (row) => {
53
48
  if (!row)
54
49
  return Object.freeze({
55
- state: authorityCollectionComplete(review) ? 'complete' : 'unavailable',
50
+ state: 'unavailable',
56
51
  values: Object.freeze([]),
57
52
  });
58
53
  if (row.observations.some((item) => item.state !== 'observed')) {
@@ -68,13 +63,15 @@ function changeSummary(before, after) {
68
63
  });
69
64
  };
70
65
  const changed = [];
66
+ const reasons = keys.length > 500 ? ['The comparison exceeds 500 owner and record-type pairs.'] : [];
71
67
  for (const key of keys.slice(0, 500)) {
72
68
  const left = beforeRows.get(key);
73
69
  const right = afterRows.get(key);
74
70
  const [owner, type] = key.split('\u0000');
75
- const beforeEvidence = rowEvidence(before, left);
76
- const afterEvidence = rowEvidence(after, right);
71
+ const beforeEvidence = rowEvidence(left);
72
+ const afterEvidence = rowEvidence(right);
77
73
  if (beforeEvidence.state !== 'complete' || afterEvidence.state !== 'complete') {
74
+ reasons.push(`Comparison evidence is incomplete for ${owner} ${type}.`);
78
75
  continue;
79
76
  }
80
77
  if (JSON.stringify(beforeEvidence.values) === JSON.stringify(afterEvidence.values))
@@ -86,7 +83,7 @@ function changeSummary(before, after) {
86
83
  afterValues: afterEvidence.values,
87
84
  }));
88
85
  }
89
- return Object.freeze(changed);
86
+ return Object.freeze({ changed: Object.freeze(changed), reasons: Object.freeze(reasons) });
90
87
  }
91
88
  export async function buildDomainChangePacket(inputRaw, generatedAtValue = new Date().toISOString()) {
92
89
  const input = record(inputRaw, 'Domain change packet input');
@@ -107,11 +104,13 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
107
104
  const preChange = reviewDomainChange(input.preChange, generatedAt);
108
105
  const postChange = reviewDomainChange(input.postChange, generatedAt);
109
106
  const assurance = buildDomainAssurance(input.assurance, generatedAt);
107
+ const comparison = changeSummary(preChange, postChange);
110
108
  const assuranceReasons = assurance.result.review.reasons;
111
109
  const gateReasons = Object.freeze([
112
110
  ...preChange.gate.reasons.map((reason) => `Pre-change evidence: ${reason}`),
113
111
  ...postChange.gate.reasons.map((reason) => `Post-change evidence: ${reason}`),
114
112
  ...assuranceReasons.map((reason) => `Change plan: ${reason}`),
113
+ ...comparison.reasons,
115
114
  ].slice(0, 100));
116
115
  const unsigned = Object.freeze({
117
116
  schema: DOMAIN_CHANGE_PACKET_SCHEMA,
@@ -122,7 +121,7 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
122
121
  state: gateReasons.length ? 'review' : 'ready',
123
122
  gate: Object.freeze({ pass: gateReasons.length === 0, reasons: gateReasons }),
124
123
  summary: Object.freeze({
125
- changedAuthoritativeRecordSets: changeSummary(preChange, postChange),
124
+ changedAuthoritativeRecordSets: comparison.changed,
126
125
  preChangeState: preChange.state,
127
126
  postChangeState: postChange.state,
128
127
  assuranceState: assurance.result.review.state,
@@ -1,13 +1,16 @@
1
1
  import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
2
  import { normaliseRdata } from "./zone-intent-review.mjs";
3
+ import { dnsQueryState, normaliseDnsQueryCoverage } from "./dns-query-coverage.mjs";
3
4
  export const DOMAIN_CHANGE_INPUT_SCHEMA = 'whoisleuth.domain-change.input';
4
- export const DOMAIN_CHANGE_INPUT_VERSION = 1;
5
+ export const DOMAIN_CHANGE_INPUT_VERSION = 2;
6
+ export const SUPPORTED_DOMAIN_CHANGE_INPUT_VERSIONS = Object.freeze([1, DOMAIN_CHANGE_INPUT_VERSION]);
5
7
  export const DOMAIN_CHANGE_REVIEW_SCHEMA = 'whoisleuth.domain-change.review';
6
- export const DOMAIN_CHANGE_REVIEW_VERSION = 2;
8
+ export const DOMAIN_CHANGE_REVIEW_VERSION = 3;
7
9
  export const MAX_DOMAIN_CHANGE_VANTAGES = 16;
8
10
  export const MAX_DOMAIN_CHANGE_RECORDS = 500;
9
11
  const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'authoritySnapshots', 'resolverSnapshots', 'acmeDependencies', 'certificate', 'hsts']);
10
12
  const SNAPSHOT_KEYS = new Set(['label', 'source', 'state', 'observedAt', 'records']);
13
+ const COVERED_SNAPSHOT_KEYS = new Set([...SNAPSHOT_KEYS, 'queries']);
11
14
  const RECORD_KEYS = new Set(['owner', 'type', 'value', 'ttl']);
12
15
  const ACME_KEYS = new Set(['method', 'owner', 'target', 'provider', 'state']);
13
16
  const CERTIFICATE_KEYS = new Set(['state', 'observedAt', 'currentSpkiSha256', 'plannedSpkiSha256', 'mustStaple', 'ocspStapled', 'embeddedSctCount']);
@@ -62,13 +65,14 @@ function normaliseRecord(value, apex, label) {
62
65
  : (() => { throw new TypeError(`${label}.ttl is outside its supported range.`); })();
63
66
  return Object.freeze({ owner: owner(input.owner, apex, `${label}.owner`), type, ttl, ...normaliseValue(type, input.value, `${label}.value`) });
64
67
  }
65
- function normaliseSnapshots(value, apex, label) {
68
+ function normaliseSnapshots(value, apex, label, legacy) {
66
69
  if (!Array.isArray(value) || value.length > MAX_DOMAIN_CHANGE_VANTAGES)
67
70
  throw new TypeError(`${label} must contain no more than ${MAX_DOMAIN_CHANGE_VANTAGES} snapshots.`);
68
71
  let totalRecords = 0;
72
+ let totalQueries = 0;
69
73
  const snapshots = value.map((raw, index) => {
70
74
  const item = requireRecord(raw, `${label}[${index}]`);
71
- exactKeys(item, SNAPSHOT_KEYS, `${label}[${index}]`);
75
+ exactKeys(item, legacy ? SNAPSHOT_KEYS : COVERED_SNAPSHOT_KEYS, `${label}[${index}]`);
72
76
  const state = evidenceState(item.state, `${label}[${index}].state`);
73
77
  if (!Array.isArray(item.records))
74
78
  throw new TypeError(`${label}[${index}].records must be an array.`);
@@ -78,12 +82,24 @@ function normaliseSnapshots(value, apex, label) {
78
82
  const records = item.records.map((recordValue, recordIndex) => normaliseRecord(recordValue, apex, `${label}[${index}].records[${recordIndex}]`));
79
83
  if (state === 'unavailable' && records.length)
80
84
  throw new TypeError(`${label}[${index}] cannot contain records when unavailable.`);
85
+ const queries = normaliseDnsQueryCoverage(item.queries, {
86
+ legacy, label: `${label}[${index}].queries`, maximum: MAX_DOMAIN_CHANGE_RECORDS - totalQueries, state, records,
87
+ owner: (value, path) => owner(value, apex, path),
88
+ type: (value, path) => {
89
+ const type = text(value, path, 16).toUpperCase();
90
+ if (!TYPES.has(type))
91
+ throw new TypeError(`${path} is unsupported.`);
92
+ return type;
93
+ },
94
+ });
95
+ totalQueries += queries.length;
81
96
  return Object.freeze({
82
97
  label: text(item.label, `${label}[${index}].label`, 120),
83
98
  source: text(item.source, `${label}[${index}].source`, 240),
84
99
  state,
85
100
  observedAt: timestamp(item.observedAt, `${label}[${index}].observedAt`),
86
101
  records: Object.freeze(records),
102
+ queries,
87
103
  });
88
104
  });
89
105
  if (new Set(snapshots.map((item) => item.label.toLowerCase())).size !== snapshots.length)
@@ -91,17 +107,18 @@ function normaliseSnapshots(value, apex, label) {
91
107
  return Object.freeze(snapshots);
92
108
  }
93
109
  function snapshotProvenance(snapshot) {
94
- return Object.freeze({ label: snapshot.label, source: snapshot.source, state: snapshot.state, observedAt: snapshot.observedAt });
110
+ return Object.freeze({ label: snapshot.label, source: snapshot.source, state: snapshot.state, observedAt: snapshot.observedAt, queries: snapshot.queries });
95
111
  }
96
112
  function recordMatrix(snapshots) {
97
- const keys = [...new Set(snapshots.flatMap((snapshot) => snapshot.records.map((item) => `${item.owner}\u0000${item.type}`)))].sort();
113
+ const keys = [...new Set(snapshots.flatMap((snapshot) => snapshot.queries.map((item) => `${item.owner}\u0000${item.type}`)))].sort();
98
114
  return Object.freeze(keys.map((key) => {
99
115
  const [recordOwner, type] = key.split('\u0000');
100
116
  const observations = snapshots.map((snapshot) => {
101
117
  const matches = snapshot.records.filter((item) => item.owner === recordOwner && item.type === type);
102
118
  const ttls = matches.map((item) => item.ttl).filter((ttl) => ttl !== null);
103
119
  return Object.freeze({
104
- ...snapshotProvenance(snapshot),
120
+ label: snapshot.label, source: snapshot.source, observedAt: snapshot.observedAt,
121
+ state: dnsQueryState(snapshot.queries, recordOwner, type),
105
122
  values: Object.freeze([...new Set(matches.map((item) => item.value))].sort()),
106
123
  ttlRange: ttls.length ? Object.freeze({
107
124
  minimum: Math.min(...ttls),
@@ -114,13 +131,14 @@ function recordMatrix(snapshots) {
114
131
  return Object.freeze({
115
132
  owner: recordOwner,
116
133
  type,
117
- state: complete.length < 2 ? 'insufficient' : signatures.size === 1 ? 'aligned' : 'different',
134
+ state: complete.length < 2 || complete.length !== observations.length ? 'insufficient' : signatures.size === 1 ? 'aligned' : 'different',
118
135
  observations: Object.freeze(observations),
119
136
  });
120
137
  }));
121
138
  }
122
139
  function reviewDnssecAutomation(matrix) {
123
- const automation = matrix.filter((row) => row.type === 'CDS' || row.type === 'CDNSKEY' || row.type === 'CSYNC');
140
+ const automation = matrix.filter((row) => ['CDS', 'CDNSKEY', 'CSYNC'].includes(row.type)
141
+ && row.observations.some((item) => item.values.length > 0));
124
142
  const byType = (type) => automation.find((row) => row.type === type);
125
143
  const cds = byType('CDS');
126
144
  const cdnskey = byType('CDNSKEY');
@@ -238,12 +256,12 @@ function serviceInventory(snapshots) {
238
256
  }
239
257
  export function reviewDomainChange(inputRaw, generatedAtValue = new Date().toISOString()) {
240
258
  const input = requireRecord(inputRaw, 'Domain change input');
241
- if (input.schema !== DOMAIN_CHANGE_INPUT_SCHEMA || input.version !== DOMAIN_CHANGE_INPUT_VERSION)
242
- throw new TypeError(`Domain change input must use ${DOMAIN_CHANGE_INPUT_SCHEMA} version ${DOMAIN_CHANGE_INPUT_VERSION}.`);
259
+ if (input.schema !== DOMAIN_CHANGE_INPUT_SCHEMA || !SUPPORTED_DOMAIN_CHANGE_INPUT_VERSIONS.includes(input.version))
260
+ throw new TypeError(`Domain change input must use ${DOMAIN_CHANGE_INPUT_SCHEMA} version 1 or ${DOMAIN_CHANGE_INPUT_VERSION}.`);
243
261
  exactKeys(input, ROOT_KEYS, 'Domain change input');
244
262
  const apex = domain(input.domain, 'domain');
245
- const authoritySnapshots = normaliseSnapshots(input.authoritySnapshots ?? [], apex, 'authoritySnapshots');
246
- const resolverSnapshots = normaliseSnapshots(input.resolverSnapshots ?? [], apex, 'resolverSnapshots');
263
+ const authoritySnapshots = normaliseSnapshots(input.authoritySnapshots ?? [], apex, 'authoritySnapshots', input.version === 1);
264
+ const resolverSnapshots = normaliseSnapshots(input.resolverSnapshots ?? [], apex, 'resolverSnapshots', input.version === 1);
247
265
  const authorityMatrix = recordMatrix(authoritySnapshots);
248
266
  const resolverMatrix = recordMatrix(resolverSnapshots);
249
267
  const acmeDependencies = normaliseAcme(input.acmeDependencies ?? [], apex);
@@ -305,7 +323,7 @@ export function reviewDomainChange(inputRaw, generatedAtValue = new Date().toISO
305
323
  limitations: Object.freeze([
306
324
  'This local review uses only analyst-supplied observations and makes no DNS, HTTP, certificate, certificate-authority, preload-list, or provider request.',
307
325
  'Different observations can reflect propagation, caching, split-horizon DNS, resolver policy, or collection timing; they do not establish misconfiguration by themselves.',
308
- 'Partial or unavailable evidence is never treated as record absence. TXT values are represented only by SHA-256 digests.',
326
+ 'Only an explicitly observed query can establish an empty answer. Unqueried, partial and unavailable record types are not absence; legacy inputs establish scope only for supplied records. TXT values are represented only by SHA-256 digests.',
309
327
  'DNSSEC automation readiness describes CDS, CDNSKEY, and CSYNC publication consistency; it does not validate a cryptographic chain or authorise a registry-side change.',
310
328
  'HSTS preload state is an analyst-supplied, source-attributed observation and must be refreshed against the nominated source before a change.',
311
329
  ]),
@@ -1,7 +1,7 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { canonicalDomainControlRecords } from "../packages/evidence/domain-control-runtime.mjs";
3
3
  import { requireBoundedString, requireDomainName, requireIsoTimestamp, } from "./bounded-contract-normalizers.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
+ import { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, DOMAIN_CONTROL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS, DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_ROOT_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA, DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS, DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_FIELD_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_SUMMARY_KEYS, PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS, SOURCE_TIMED_FLIGHT_RECORDER_EVENT_KEYS, SOURCE_TIMED_FLIGHT_RECORDER_LIMITATIONS, FLIGHT_RECORDER_CHANGE_INTERVAL_KEYS, HISTORICAL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS, PUBLIC_MAX_FLIGHT_RECORDER_INPUT_VALUES, PUBLIC_MAX_FLIGHT_RECORDER_VALUES, PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH, SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS, DOMAIN_CONTROL_FLIGHT_RECORDER_WINDOW_KEYS, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_BYTES, MAX_DOMAIN_CONTROL_FLIGHT_RECORDER_OUTPUT_BYTES, MAX_FLIGHT_RECORDER_FIELDS, MAX_FLIGHT_RECORDER_EVENTS, MAX_FLIGHT_RECORDER_INPUT_VALUES, MAX_FLIGHT_RECORDER_JSON_DEPTH, MAX_FLIGHT_RECORDER_JSON_VALUES, MAX_FLIGHT_RECORDER_OBSERVATIONS, MAX_FLIGHT_RECORDER_SOURCE_LENGTH, MAX_FLIGHT_RECORDER_VALUE_LENGTH, MAX_FLIGHT_RECORDER_VALUES, MAX_FLIGHT_RECORDER_WINDOWS, MAX_FLIGHT_RECORDER_WINDOW_ID_LENGTH, MAX_FLIGHT_RECORDER_WINDOW_REASON_LENGTH, MIN_FLIGHT_RECORDER_OBSERVATIONS, } from "../packages/contracts/domain-control-flight-recorder.mjs";
5
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, };
6
6
  const ROOT_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_INPUT_KEYS);
7
7
  const OBSERVATION_KEYS = new Set(DOMAIN_CONTROL_FLIGHT_RECORDER_OBSERVATION_KEYS);
@@ -378,12 +378,11 @@ function approvedWindow(value, index) {
378
378
  function sameValues(left, right) {
379
379
  return left.length === right.length && left.every((value, index) => value === right[index]);
380
380
  }
381
- function matchingWindow(windows, domain, field, observedAt) {
382
- const time = Date.parse(observedAt);
381
+ function matchingWindow(windows, domain, field, after, by) {
383
382
  return windows.find((window) => (window.domain === domain
384
383
  && window.fields.includes(field)
385
- && Date.parse(window.startsAt) <= time
386
- && time <= Date.parse(window.endsAt))) ?? null;
384
+ && Date.parse(window.startsAt) <= Date.parse(after)
385
+ && Date.parse(by) <= Date.parse(window.endsAt))) ?? null;
387
386
  }
388
387
  /** Equal-capture disagreement stays incomplete, independently of input ordering. */
389
388
  export function mergeConcurrentDomainControlFields(observations) {
@@ -441,12 +440,22 @@ export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = ne
441
440
  throw new TypeError('Domain control flight-recorder captures cannot be later than generatedAt.');
442
441
  }
443
442
  const latest = new Map();
443
+ const lastComplete = new Map();
444
+ const incompleteSince = new Map();
444
445
  const events = [];
445
446
  let incompleteFields = 0;
446
447
  for (const item of observationTimeline(observations)) {
447
- for (const field of item.fields) {
448
+ const fields = [...item.fields];
449
+ for (const [key, previous] of latest) {
450
+ if (key.startsWith(`${item.domain}\u0000`) && !fields.some(field => field.id === previous.field.id)) {
451
+ fields.push({ id: previous.field.id, source: 'Not supplied in this capture', state: 'unavailable', observedAt: null, values: [] });
452
+ }
453
+ }
454
+ fields.sort((a, b) => ordinalCompare(a.id, b.id));
455
+ for (const field of fields) {
448
456
  const key = `${item.domain}\u0000${field.id}`;
449
457
  const previous = latest.get(key);
458
+ let baseline = lastComplete.get(key);
450
459
  let complete = field.state === 'observed' && field.observedAt !== null
451
460
  && Date.parse(field.observedAt) <= Date.parse(item.capturedAt) && item.collectionDepth !== 'unknown';
452
461
  const sameContext = previous?.field.source === field.source && previous.collectionDepth === item.collectionDepth;
@@ -458,46 +467,67 @@ export function buildDomainControlFlightRecorder(inputRaw, generatedAtValue = ne
458
467
  : Date.parse(field.observedAt) > Date.parse(item.capturedAt) ? 'The source observation time is later than capture.'
459
468
  : item.collectionDepth === 'unknown' ? 'Collection conditions are unknown or conflicting.'
460
469
  : `The source is ${field.state}.`;
461
- if (complete && previous?.complete && (!sameContext
462
- || Date.parse(field.observedAt) <= Date.parse(previous.field.observedAt))) {
470
+ const contextChanged = baseline && field.source !== 'Not supplied in this capture'
471
+ && (baseline.field.source !== field.source || (item.collectionDepth !== 'unknown' && baseline.collectionDepth !== item.collectionDepth));
472
+ if (contextChanged) {
473
+ if (complete) {
474
+ complete = false;
475
+ limitation = 'The source identity or collection conditions changed.';
476
+ }
477
+ lastComplete.delete(key);
478
+ baseline = undefined;
479
+ }
480
+ else if (complete && baseline && Date.parse(field.observedAt) <= Date.parse(baseline.field.observedAt)) {
463
481
  complete = false;
464
- limitation = !sameContext ? 'The source identity or collection conditions changed.'
465
- : 'The source observation time did not increase.';
482
+ limitation = 'The source observation time did not increase.';
466
483
  }
467
484
  latest.set(key, { field, collectionDepth: item.collectionDepth, complete });
468
485
  let kind;
469
486
  let explanation;
470
487
  let before = [];
471
488
  let approved = null;
489
+ let changeInterval = null;
472
490
  if (!complete) {
491
+ incompleteSince.set(key, (incompleteSince.get(key) ?? 0) + 1);
473
492
  incompleteFields += 1;
474
493
  kind = previous?.complete ? 'collection_change' : 'incomplete_observation';
475
494
  before = previous?.complete ? previous.field.values : [];
476
495
  explanation = `${limitation} Retained values are incomplete; this is not evidence that a prior value disappeared or changed.`;
477
496
  }
497
+ else if (baseline && !sameValues(baseline.field.values, field.values)) {
498
+ kind = 'observed_change';
499
+ before = baseline.field.values;
500
+ changeInterval = Object.freeze({ after: baseline.field.observedAt, by: field.observedAt, incompleteObservations: incompleteSince.get(key) ?? 0 });
501
+ approved = matchingWindow(windows, item.domain, field.id, changeInterval.after, changeInterval.by);
502
+ explanation = `Comparable complete observations contain different values. The change occurred after ${changeInterval.after} and by ${changeInterval.by}; its exact time is unknown.`
503
+ + (changeInterval.incompleteObservations ? ` ${changeInterval.incompleteObservations} incomplete observation(s) occurred in this interval.` : '')
504
+ + (approved ? ' The recorded approved window contains the entire interval.' : ' No recorded approved window contains the entire interval.');
505
+ }
478
506
  else if (!previous) {
479
507
  kind = 'first_observation';
480
508
  explanation = 'The first complete source-timed value for this field was retained.';
481
509
  }
482
510
  else if (!previous.complete) {
483
511
  kind = 'recovered';
484
- explanation = 'A complete source-timed value is available after an incomplete observation; this does not establish a value change.';
512
+ explanation = baseline
513
+ ? 'The recovered complete value matches the last comparable complete observation. Intervening changes during the incomplete interval remain unknown.'
514
+ : 'A complete source-timed value is available after an incomplete observation; this does not establish a value change.';
485
515
  }
486
516
  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.';
517
+ lastComplete.set(key, { field, collectionDepth: item.collectionDepth, complete });
518
+ incompleteSince.delete(key);
519
+ continue;
520
+ }
521
+ if (complete) {
522
+ lastComplete.set(key, { field, collectionDepth: item.collectionDepth, complete });
523
+ incompleteSince.delete(key);
495
524
  }
496
525
  events.push(Object.freeze({
497
526
  id: `${item.domain}:${field.id}:${item.capturedAt}:${events.length + 1}`,
498
527
  domain: item.domain, field: field.id, observedAt: field.observedAt, capturedAt: item.capturedAt,
499
528
  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,
529
+ source: field.source, approvedWindow: approved ? Object.freeze({ id: approved.id, reason: approved.reason, startsAt: approved.startsAt, endsAt: approved.endsAt }) : null,
530
+ collectionDepth: item.collectionDepth, changeInterval,
501
531
  explanation,
502
532
  }));
503
533
  }
@@ -560,6 +590,7 @@ export function validateDomainControlFlightRecorderDocument(value) {
560
590
  throw new TypeError(`Domain control flight-recorder document must use ${DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA} supported version ${SUPPORTED_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSIONS.join(' or ')}.`);
561
591
  }
562
592
  const legacy = root.version === PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION;
593
+ const intervalVersion = root.version === DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION;
563
594
  const generatedAt = requireIsoTimestamp(root.generatedAt, 'Domain control flight-recorder document generatedAt');
564
595
  if (generatedAt !== root.generatedAt) {
565
596
  throw new TypeError('Domain control flight-recorder document generatedAt must use its canonical timestamp form.');
@@ -591,10 +622,13 @@ export function validateDomainControlFlightRecorderDocument(value) {
591
622
  };
592
623
  const seenIds = new Set();
593
624
  const latest = new Map();
625
+ const lastComplete = new Map();
626
+ const incompleteSince = new Map();
594
627
  let lastCapture = -Infinity;
595
628
  const events = eventInput.map((eventValue, index) => {
596
629
  const label = `Domain control flight-recorder document event ${index + 1}`;
597
- const event = exactRecord(eventValue, legacy ? new Set(PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS) : EVENT_KEYS, label);
630
+ const event = exactRecord(eventValue, legacy ? new Set(PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_EVENT_KEYS)
631
+ : intervalVersion ? EVENT_KEYS : new Set(SOURCE_TIMED_FLIGHT_RECORDER_EVENT_KEYS), label);
598
632
  const eventDomain = requireDomainName(event.domain, `${label}.domain`);
599
633
  if (eventDomain !== event.domain || !domains.includes(eventDomain)) {
600
634
  throw new TypeError(`${label}.domain must be present in the document domain list.`);
@@ -624,20 +658,44 @@ export function validateDomainControlFlightRecorderDocument(value) {
624
658
  if (!legacy && expectedState === 'observed' && (observedAt === null || Date.parse(observedAt) > Date.parse(capturedAt))) {
625
659
  throw new TypeError(`${label} cannot claim a complete observation without a source time at or before capture.`);
626
660
  }
661
+ const collectionDepth = intervalVersion ? event.collectionDepth : undefined;
662
+ if (intervalVersion && (collectionDepth !== 'deep' && collectionDepth !== 'fast' && collectionDepth !== 'unknown'
663
+ || expectedState === 'observed' && collectionDepth === 'unknown')) {
664
+ throw new TypeError(`${label} has invalid collection conditions.`);
665
+ }
627
666
  const before = exactRecorderValues(event.before, `${label}.before`, legacy, event.field, expectedState === 'observed');
628
667
  const after = exactRecorderValues(event.after, `${label}.after`, legacy, event.field, expectedState === 'observed');
629
668
  if (((event.kind === 'first_observation' || event.kind === 'recovered') && before.length)
630
669
  || (legacy && event.kind === 'collection_change' && after.length)) {
631
670
  throw new TypeError(`${label} before/after values are inconsistent.`);
632
671
  }
672
+ let changeInterval = null;
673
+ if (intervalVersion && event.changeInterval !== null) {
674
+ const interval = exactRecord(event.changeInterval, new Set(FLIGHT_RECORDER_CHANGE_INTERVAL_KEYS), `${label}.changeInterval`);
675
+ const after = requireIsoTimestamp(interval.after, `${label}.changeInterval.after`);
676
+ const by = requireIsoTimestamp(interval.by, `${label}.changeInterval.by`);
677
+ if (kind !== 'observed_change' || after !== interval.after || by !== interval.by || by !== observedAt || Date.parse(after) >= Date.parse(by)) {
678
+ throw new TypeError(`${label} has an inconsistent change interval.`);
679
+ }
680
+ changeInterval = Object.freeze({ after, by, incompleteObservations: exactRecorderInteger(interval.incompleteObservations, 0, MAX_FLIGHT_RECORDER_OBSERVATIONS, `${label}.changeInterval.incompleteObservations`) });
681
+ }
682
+ if (intervalVersion && (kind === 'observed_change') !== (changeInterval !== null))
683
+ throw new TypeError(`${label} must retain its change interval.`);
633
684
  let approvedWindow = null;
634
685
  if (event.approvedWindow !== null) {
635
686
  if (event.kind !== 'observed_change')
636
687
  throw new TypeError(`${label}.approvedWindow is inconsistent.`);
637
- const window = exactRecord(event.approvedWindow, EVENT_APPROVED_WINDOW_KEYS, `${label}.approvedWindow`);
688
+ const window = exactRecord(event.approvedWindow, intervalVersion ? EVENT_APPROVED_WINDOW_KEYS : new Set(HISTORICAL_FLIGHT_RECORDER_APPROVED_WINDOW_KEYS), `${label}.approvedWindow`);
689
+ const startsAt = intervalVersion ? requireIsoTimestamp(window.startsAt, `${label}.approvedWindow.startsAt`) : undefined;
690
+ const endsAt = intervalVersion ? requireIsoTimestamp(window.endsAt, `${label}.approvedWindow.endsAt`) : undefined;
691
+ if (intervalVersion && (!changeInterval || startsAt !== window.startsAt || endsAt !== window.endsAt
692
+ || Date.parse(startsAt) > Date.parse(changeInterval.after) || Date.parse(endsAt) < Date.parse(changeInterval.by))) {
693
+ throw new TypeError(`${label}.approvedWindow does not contain the change interval.`);
694
+ }
638
695
  approvedWindow = Object.freeze({
639
696
  id: exactRecorderText(window.id, `${label}.approvedWindow.id`, MAX_FLIGHT_RECORDER_WINDOW_ID_LENGTH),
640
697
  reason: exactRecorderText(window.reason, `${label}.approvedWindow.reason`, MAX_FLIGHT_RECORDER_WINDOW_REASON_LENGTH),
698
+ ...(intervalVersion ? { startsAt: startsAt, endsAt: endsAt } : {}),
641
699
  });
642
700
  kindCounts.approved_change += 1;
643
701
  }
@@ -655,16 +713,33 @@ export function validateDomainControlFlightRecorderDocument(value) {
655
713
  source: exactRecorderText(event.source, `${label}.source`, MAX_FLIGHT_RECORDER_SOURCE_LENGTH),
656
714
  approvedWindow,
657
715
  explanation: exactRecorderText(event.explanation, `${label}.explanation`, legacy ? PUBLIC_MAX_FLIGHT_RECORDER_VALUE_LENGTH : MAX_FLIGHT_RECORDER_VALUE_LENGTH),
716
+ ...(intervalVersion ? { collectionDepth: collectionDepth, changeInterval } : {}),
658
717
  });
659
718
  if (!legacy) {
660
- const previous = latest.get(`${eventDomain}\u0000${event.field}`);
719
+ const key = `${eventDomain}\u0000${event.field}`;
720
+ const previous = intervalVersion ? lastComplete.get(key) : latest.get(key);
661
721
  if (seenIds.has(result.id) || (kind === 'observed_change' && (!previous || previous.state !== 'observed'
662
722
  || previous.source !== result.source || Date.parse(previous.observedAt) >= Date.parse(result.observedAt)
663
- || !sameValues(previous.after, before) || sameValues(before, after)))) {
723
+ || !sameValues(previous.after, before) || sameValues(before, after)
724
+ || intervalVersion && (previous.collectionDepth !== result.collectionDepth || !changeInterval
725
+ || Date.parse(changeInterval.after) < Date.parse(previous.observedAt)
726
+ || changeInterval.incompleteObservations !== (incompleteSince.get(key) ?? 0))))) {
664
727
  throw new TypeError(`${label} has a duplicate identity or an unsupported observed-change claim.`);
665
728
  }
666
729
  seenIds.add(result.id);
667
- latest.set(`${eventDomain}\u0000${event.field}`, result);
730
+ latest.set(key, result);
731
+ if (intervalVersion) {
732
+ if (expectedState === 'observed') {
733
+ lastComplete.set(key, result);
734
+ incompleteSince.delete(key);
735
+ }
736
+ else {
737
+ incompleteSince.set(key, (incompleteSince.get(key) ?? 0) + 1);
738
+ if (previous && result.source !== 'Not supplied in this capture'
739
+ && (previous.source !== result.source || result.collectionDepth !== 'unknown' && previous.collectionDepth !== result.collectionDepth))
740
+ lastComplete.delete(key);
741
+ }
742
+ }
668
743
  }
669
744
  return result;
670
745
  });
@@ -685,14 +760,15 @@ export function validateDomainControlFlightRecorderDocument(value) {
685
760
  throw new TypeError('Domain control flight-recorder document summary is inconsistent.');
686
761
  }
687
762
  }
688
- const limitations = legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS : DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS;
763
+ const limitations = legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS
764
+ : intervalVersion ? DOMAIN_CONTROL_FLIGHT_RECORDER_LIMITATIONS : SOURCE_TIMED_FLIGHT_RECORDER_LIMITATIONS;
689
765
  const limitationsInput = boundedDataArray(root.limitations, 'Domain control flight-recorder document limitations', limitations.length, limitations.length);
690
766
  if (limitationsInput.some((item, index) => item !== limitations[index])) {
691
767
  throw new TypeError('Domain control flight-recorder document limitations are invalid.');
692
768
  }
693
769
  const document = Object.freeze({
694
770
  schema: DOMAIN_CONTROL_FLIGHT_RECORDER_SCHEMA,
695
- version: legacy ? PUBLIC_DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION : DOMAIN_CONTROL_FLIGHT_RECORDER_VERSION,
771
+ version: root.version,
696
772
  generatedAt,
697
773
  domains: Object.freeze(domains),
698
774
  observationCount,
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { domainToASCII } from 'node:url';
3
3
  import { canonicalArtifactJsonFor, } from "../packages/evidence/artifact-integrity.mjs";
4
4
  import { DOMAIN_CONTROL_MANIFEST_CURRENT_CANONICALIZATION, DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR, MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS, MAX_CURRENT_DOMAIN_CONTROL_RECORDS, DOMAIN_CONTROL_RECORD_LIST_FIELDS, DOMAIN_CONTROL_RECORD_MODE_OPTIONS, PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION, MAX_DOMAIN_CONTROL_DS_PRESENTATION_LENGTH, MAX_DOMAIN_CONTROL_INPUT_RECORDS, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES, MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH, } from "../packages/contracts/domain-control-manifest.mjs";
5
- import { DOMAIN_CONTROL_REVIEW_FIELDS, DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, DOMAIN_CONTROL_REVIEW_COUNT_KEYS, DOMAIN_CONTROL_REVIEW_DOMAIN_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_LIMITATIONS, DOMAIN_CONTROL_REVIEW_MANIFEST_SUMMARY_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_KEYS, DOMAIN_CONTROL_REVIEW_ROOT_KEYS, DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION, PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION, SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS, PUBLIC_DOMAIN_CONTROL_REVIEW_COUNT_KEYS, PUBLIC_DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, PUBLIC_DOMAIN_CONTROL_REVIEW_LIMITATIONS, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, } from "../packages/contracts/domain-control-review.mjs";
5
+ import { DOMAIN_CONTROL_REVIEW_FIELDS, DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, DOMAIN_CONTROL_REVIEW_COUNT_KEYS, DOMAIN_CONTROL_REVIEW_DOMAIN_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_LIMITATIONS, domainControlReviewCountKeys, domainControlReviewLimitations, DOMAIN_CONTROL_REVIEW_MANIFEST_SUMMARY_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_KEYS, DOMAIN_CONTROL_REVIEW_ROOT_KEYS, DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION, PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION, SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS, PUBLIC_DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, } from "../packages/contracts/domain-control-review.mjs";
6
6
  import { assertDomainControlManifestByteBudget, buildUnsignedDomainControlManifest, canonicalDomainControlRecordList, domainControlRecordMode, domainControlEvidenceAdmission, normalizeDomainControlManifestDocument, } from "../packages/evidence/domain-control-runtime.mjs";
7
7
  import { exactKeys } from "./bounded-contract-normalizers.mjs";
8
8
  import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../packages/evidence/observation.mjs";
@@ -323,28 +323,36 @@ function renewalComparison(entry, now) {
323
323
  });
324
324
  const due = Date.parse(entry.renewalReviewAt) <= Date.parse(now);
325
325
  return Object.freeze({
326
- field: 'renewalReviewAt', state: due ? 'due' : 'aligned', desired: Object.freeze([entry.renewalReviewAt]), observed: Object.freeze([]), source: null, observedAt: null,
326
+ field: 'renewalReviewAt', state: due ? 'due' : 'not_due', desired: Object.freeze([entry.renewalReviewAt]), observed: Object.freeze([]), source: null, observedAt: null,
327
327
  explanation: due ? 'The configured renewal review date is due.' : 'The configured renewal review date is still in the future.',
328
328
  expectation: 'expect_records',
329
329
  });
330
330
  }
331
- function aggregateComparisonState(comparisons, legacy = false) {
332
- if (comparisons.some((item) => item.state === 'drift' || item.state === 'due'))
331
+ function aggregateComparisonState(comparisons, version = DOMAIN_CONTROL_REVIEW_VERSION) {
332
+ if (comparisons.some((item) => item.state === 'drift' || (version < 3 && item.state === 'due')))
333
333
  return 'drift';
334
334
  if (comparisons.some((item) => ['partial', 'unavailable', 'unsupported'].includes(item.state)))
335
335
  return 'partial';
336
- if (legacy || comparisons.some((item) => item.state === 'aligned'))
336
+ if (comparisons.some((item) => item.state === 'due'))
337
+ return 'due';
338
+ if (version === 1 || comparisons.some((item) => item.state === 'aligned'))
337
339
  return 'aligned';
338
- return comparisons.some((item) => item.state === 'observed') ? 'observed' : 'not_configured';
340
+ if (comparisons.some((item) => item.state === 'observed'))
341
+ return 'observed';
342
+ return comparisons.some((item) => item.state === 'not_due') ? 'not_due' : 'not_configured';
339
343
  }
340
- function aggregateDomainState(domains, legacy = false) {
344
+ function aggregateDomainState(domains, version = DOMAIN_CONTROL_REVIEW_VERSION) {
341
345
  if (domains.some((item) => item.state === 'drift'))
342
346
  return 'drift';
343
347
  if (domains.some((item) => item.state === 'partial'))
344
348
  return 'partial';
345
- if (legacy || domains.some((item) => item.state === 'aligned'))
349
+ if (domains.some((item) => item.state === 'due'))
350
+ return 'due';
351
+ if (version === 1 || domains.some((item) => item.state === 'aligned'))
346
352
  return 'aligned';
347
- return domains.some((item) => item.state === 'observed') ? 'observed' : 'not_configured';
353
+ if (domains.some((item) => item.state === 'observed'))
354
+ return 'observed';
355
+ return domains.some((item) => item.state === 'not_due') ? 'not_due' : 'not_configured';
348
356
  }
349
357
  export function reviewDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
350
358
  const source = record(input);
@@ -405,10 +413,10 @@ export function validateDomainControlReviewDocument(value) {
405
413
  throw new TypeError(`Domain control review document must use ${DOMAIN_CONTROL_REVIEW_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
406
414
  }
407
415
  const legacy = root.version === PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION;
408
- const countKeys = legacy ? PUBLIC_DOMAIN_CONTROL_REVIEW_COUNT_KEYS : DOMAIN_CONTROL_REVIEW_COUNT_KEYS;
416
+ const countKeys = domainControlReviewCountKeys(root.version);
409
417
  const comparisonKeys = legacy ? new Set(PUBLIC_DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS) : REVIEW_COMPARISON_KEYS;
410
418
  const recordMaximum = legacy ? MAX_CANONICAL_DOMAIN_CONTROL_RECORDS : MAX_DOMAIN_CONTROL_RECORDS;
411
- const limitations = legacy ? PUBLIC_DOMAIN_CONTROL_REVIEW_LIMITATIONS : DOMAIN_CONTROL_REVIEW_LIMITATIONS;
419
+ const limitations = domainControlReviewLimitations(root.version);
412
420
  const generatedAt = exactReviewTimestamp(root.generatedAt, 'Domain control review document generatedAt');
413
421
  const manifestInput = exactReviewRecord(root.manifest, REVIEW_MANIFEST_KEYS, 'Domain control review document manifest');
414
422
  const manifestGeneratedAt = exactReviewTimestamp(manifestInput.generatedAt, 'Domain control review document manifest generatedAt');
@@ -492,16 +500,16 @@ export function validateDomainControlReviewDocument(value) {
492
500
  || (expectation === 'unconfigured' && (state !== 'not_configured' || observed.length || source !== null || observedAt !== null))
493
501
  || (state === 'not_configured' && expectation !== 'unconfigured')
494
502
  || (state === 'observed' && expectation !== 'observe_only')
495
- || (['aligned', 'drift', 'due'].includes(state) && ['unconfigured', 'observe_only'].includes(expectation))) {
503
+ || (['aligned', 'drift', 'due', 'not_due'].includes(state) && ['unconfigured', 'observe_only'].includes(expectation))) {
496
504
  throw new TypeError('Domain control review comparison expectation is inconsistent.');
497
505
  }
498
506
  if (expectedField === 'renewalReviewAt' && expectation === 'expect_records') {
499
507
  if (desired.length !== 1 || normalizeExplicitIsoTimestamp(desired[0]) !== desired[0]
500
- || state !== (Date.parse(desired[0]) <= Date.parse(generatedAt) ? 'due' : 'aligned'))
508
+ || state !== (Date.parse(desired[0]) <= Date.parse(generatedAt) ? 'due' : root.version === 2 ? 'aligned' : 'not_due'))
501
509
  throw new TypeError('Domain control renewal review state is inconsistent.');
502
510
  }
503
- else if (state === 'due')
504
- throw new TypeError('Only renewal review dates can be due.');
511
+ else if (state === 'due' || state === 'not_due')
512
+ throw new TypeError('Only renewal review dates can be due or not due.');
505
513
  if (expectedField !== 'renewalReviewAt' && ['aligned', 'drift', 'observed'].includes(state)) {
506
514
  if (!source || domainControlEvidenceAdmission(observedAt, generatedAt))
507
515
  throw new TypeError('Domain control review requires current source timing for a conclusive comparison.');
@@ -521,7 +529,7 @@ export function validateDomainControlReviewDocument(value) {
521
529
  ...(expectation !== undefined ? { expectation } : {}),
522
530
  });
523
531
  });
524
- const expectedState = aggregateComparisonState(comparisons, legacy);
532
+ const expectedState = aggregateComparisonState(comparisons, root.version);
525
533
  if (item.state !== expectedState) {
526
534
  throw new TypeError(`Domain control review document domain ${domainIndex + 1} state is inconsistent.`);
527
535
  }
@@ -535,7 +543,7 @@ export function validateDomainControlReviewDocument(value) {
535
543
  throw new TypeError('Domain control review document counts are inconsistent.');
536
544
  }
537
545
  }
538
- const expectedState = expired ? 'expired' : aggregateDomainState(domains, legacy);
546
+ const expectedState = expired ? 'expired' : aggregateDomainState(domains, root.version);
539
547
  if (root.state !== expectedState)
540
548
  throw new TypeError('Domain control review document state is inconsistent.');
541
549
  const ignoredObservationCount = exactReviewInteger(root.ignoredObservationCount, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, 'Domain control review document ignored observation count');