@slicedearth/whoisleuth-cli 1.36.1 → 1.43.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 (139) hide show
  1. package/README.md +32 -2
  2. package/bin/whoisleuth.mjs +52 -24
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +493 -157
  5. package/cli/artifact-verify.mjs +91 -15
  6. package/cli/bulk-checkpoint.mjs +7 -11
  7. package/cli/bulk-command-runner.mjs +19 -2
  8. package/cli/case-pack.mjs +136 -0
  9. package/cli/ci-report.mjs +73 -0
  10. package/cli/collection-preflight.mjs +59 -0
  11. package/cli/command-reference.mjs +395 -0
  12. package/cli/compare.mjs +1 -1
  13. package/cli/completion.mjs +49 -11
  14. package/cli/config-profile.mjs +180 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-scan-command-runner.mjs +15 -1
  17. package/cli/doctor.mjs +11 -5
  18. package/cli/domain-control-monitor.mjs +104 -0
  19. package/cli/domain-control-observations.mjs +235 -0
  20. package/cli/evidence-command-arguments.mjs +12 -1
  21. package/cli/evidence-command-runner.mjs +3 -2
  22. package/cli/evidence-signing.mjs +4 -4
  23. package/cli/external-observation-mapping.mjs +162 -0
  24. package/cli/fail-policy.mjs +64 -0
  25. package/cli/formatters/html.mjs +2 -2
  26. package/cli/formatters/json.mjs +13 -1
  27. package/cli/formatters/terminal.mjs +7 -3
  28. package/cli/investigation-manifest.mjs +113 -0
  29. package/cli/investigation-plan.mjs +2 -2
  30. package/cli/investigation-run.mjs +112 -0
  31. package/cli/local-mmdb-review.mjs +76 -0
  32. package/cli/lookalike-calibration.mjs +121 -0
  33. package/cli/lookup-brief.mjs +116 -0
  34. package/cli/lookup-command-runner.mjs +12 -2
  35. package/cli/lookup-diff.mjs +17 -19
  36. package/cli/lookup-plan.mjs +3 -3
  37. package/cli/lookup-reconcile.mjs +141 -0
  38. package/cli/lookup-timeline.mjs +1 -1
  39. package/cli/mail-review.mjs +1 -1
  40. package/cli/network-command-runner.mjs +4 -1
  41. package/cli/offline-evidence-review.mjs +125 -10
  42. package/cli/open-asset-model-bridge.mjs +169 -0
  43. package/cli/registry-cohort.mjs +121 -0
  44. package/cli/registry-doctor.mjs +220 -0
  45. package/cli/registry-fixture-scaffold.mjs +73 -0
  46. package/cli/risk-calibration.mjs +117 -9
  47. package/cli/runner.mjs +561 -280
  48. package/cli/saved-lookup.mjs +2 -2
  49. package/cli/sharing-review.mjs +202 -0
  50. package/cli/source-reliability.mjs +1 -1
  51. package/docs/cli-reference.md +1336 -0
  52. package/docs/cli.md +259 -940
  53. package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
  54. package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
  55. package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
  56. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
  57. package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
  58. package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
  59. package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
  60. package/frontend/src/lib/analysis/case-migration-model.js +6 -1
  61. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  62. package/frontend/src/lib/analysis/case-record-core.js +5 -1
  63. package/frontend/src/lib/analysis/case-record-operations.js +6 -1
  64. package/frontend/src/lib/analysis/case-report.js +573 -0
  65. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  66. package/frontend/src/lib/analysis/case-response-packet.js +7 -7
  67. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  68. package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
  69. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  70. package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
  71. package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
  72. package/frontend/src/lib/analysis/lookup-decision-support.js +487 -0
  73. package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
  74. package/frontend/src/lib/analysis/lookup-presentation.js +64 -0
  75. package/frontend/src/lib/analysis/lookup-response.js +2 -0
  76. package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
  77. package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
  78. package/frontend/src/lib/analysis/page-similarity.js +1 -1
  79. package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
  80. package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
  81. package/frontend/src/lib/analysis/scoring.js +9 -89
  82. package/frontend/src/lib/analysis/shortlist-model.js +8 -4
  83. package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
  84. package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
  85. package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
  86. package/frontend/src/lib/analysis/visualization-models.js +369 -0
  87. package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
  88. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
  89. package/frontend/src/lib/analysis/workspace-archive.js +4 -2
  90. package/lib/abusech-auth.mjs +1 -1
  91. package/lib/analyst-taxonomy.mjs +40 -0
  92. package/lib/availability.mjs +2 -2
  93. package/lib/bounded-contract-normalizers.mjs +33 -0
  94. package/lib/browser-library-profile.mjs +15 -2
  95. package/lib/certificate-extension-profile.mjs +71 -0
  96. package/lib/credential-surface-profile.mjs +1 -1
  97. package/lib/dns-convergence-review.mjs +183 -0
  98. package/lib/dns-delegation-health.mjs +196 -3
  99. package/lib/domain-assurance.mjs +368 -0
  100. package/lib/domain-change-packet.mjs +133 -0
  101. package/lib/domain-change-review.mjs +307 -0
  102. package/lib/domain-control-flight-recorder.mjs +268 -0
  103. package/lib/domain-control-manifest.mjs +50 -7
  104. package/lib/domain-portfolio-review.mjs +151 -0
  105. package/lib/domain-posture.mjs +9 -9
  106. package/lib/encrypted-dns-contract.mjs +1 -1
  107. package/lib/evidence-export.mjs +1 -1
  108. package/lib/evidence-report-markdown.mjs +1 -1
  109. package/lib/evidence-report.mjs +1 -1
  110. package/lib/generated/cisa-kev-catalog.mjs +1668 -0
  111. package/lib/html-signals.mjs +5 -11
  112. package/lib/ip-prefix.mjs +34 -1
  113. package/lib/local-geoip-evidence.mjs +2 -2
  114. package/lib/lookup-response-contract.mjs +346 -0
  115. package/lib/nameserver-preflight-review.mjs +159 -0
  116. package/lib/observed-network-context.mjs +1 -1
  117. package/lib/opportunity-scoring.mjs +107 -0
  118. package/lib/page-fingerprints.mjs +1 -1
  119. package/lib/page-language-signals.mjs +124 -0
  120. package/lib/rdap-capabilities.mjs +6 -6
  121. package/lib/rdap-search-workbench.mjs +96 -6
  122. package/lib/registry-access.mjs +1 -1
  123. package/lib/registry-capability-catalogue.mjs +4 -4
  124. package/lib/registry-insights.mjs +6 -6
  125. package/lib/risk-scoring.mjs +247 -83
  126. package/lib/scoring-evidence-quality.mjs +107 -0
  127. package/lib/security-txt.mjs +3 -3
  128. package/lib/static-html-analysis.mjs +29 -1
  129. package/lib/threat-intelligence-manifests.mjs +1 -1
  130. package/lib/threatfox-intelligence.mjs +1 -1
  131. package/lib/tls-intelligence.mjs +13 -3
  132. package/lib/trust-store-comparison.mjs +146 -0
  133. package/lib/urlhaus-intelligence.mjs +1 -1
  134. package/lib/website-security-posture.mjs +5 -5
  135. package/lib/website-technology.mjs +25 -11
  136. package/lib/whois-chain.mjs +1 -1
  137. package/lib/zone-intent-review.mjs +534 -0
  138. package/package.json +5 -1
  139. package/third-party-notices.txt +911 -19
@@ -72,7 +72,7 @@ export async function buildAcquisitionDecisionPacket(input) {
72
72
  },
73
73
  evidenceReview: input.review,
74
74
  limitations: [
75
- 'This local artifact records an analyst review of bounded point-in-time evidence and does not submit, reserve, value, purchase, or transfer a domain.',
75
+ 'This local artefact records an analyst review of bounded point-in-time evidence and does not submit, reserve, value, purchase, or transfer a domain.',
76
76
  'A reviewed state records completion of the displayed manual checklist, not the accuracy of external statements or a legal, financial, eligibility, or ownership determination.',
77
77
  'Refresh authoritative registration and policy evidence immediately before acting.',
78
78
  ...(input.synthetic === true
@@ -102,7 +102,7 @@ function lifecycleItem(availability, lifecycle, publications) {
102
102
  label: 'Lifecycle date observed',
103
103
  state: expiresInDays !== null && expiresInDays <= 30 ? 'review' : 'observed',
104
104
  detail: timing,
105
- provenance: 'Normalized RDAP or WHOIS lifecycle',
105
+ provenance: 'Normalised RDAP or WHOIS lifecycle',
106
106
  };
107
107
  }
108
108
  return {
@@ -110,7 +110,7 @@ function lifecycleItem(availability, lifecycle, publications) {
110
110
  label: completePublication ? 'Expiry date not published' : 'Lifecycle timing unavailable',
111
111
  state: completePublication ? 'observed' : 'unavailable',
112
112
  detail: completePublication
113
- ? 'A usable publication was collected without a normalized expiry date. Registry policy and renewal state still require manual confirmation.'
113
+ ? 'A usable publication was collected without a normalised expiry date. Registry policy and renewal state still require manual confirmation.'
114
114
  : 'Incomplete or unavailable registry publications cannot establish an expiry or release window.',
115
115
  provenance: 'Registry publication quality',
116
116
  };
@@ -135,7 +135,7 @@ function transferItem(lifecycle, publications) {
135
135
  id: 'transfer',
136
136
  label: 'No interpreted lock status observed',
137
137
  state: 'review',
138
- detail: 'The collected statuses did not contain a recognized client or server lock. This does not prove transfer eligibility or that other policy restrictions are absent.',
138
+ detail: 'The collected statuses did not contain a recognised client or server lock. This does not prove transfer eligibility or that other policy restrictions are absent.',
139
139
  provenance: 'EPP status interpretation',
140
140
  };
141
141
  }
@@ -307,7 +307,7 @@ function registryPolicyChecks(availability, lifecycle, publications) {
307
307
  detail: statuses.length
308
308
  ? `The collected publication included ${statuses.slice(0, 6).join(', ')}${statuses.length > 6 ? ', …' : ''}. Confirm current registry timing and restoration or deletion rules; WHOISleuth does not predict release.`
309
309
  : publicationAvailable
310
- ? 'No recognized lifecycle status was retained. Confirm renewal, grace, redemption, restoration, and deletion rules with the current registry or registrar.'
310
+ ? 'No recognised lifecycle status was retained. Confirm renewal, grace, redemption, restoration, and deletion rules with the current registry or registrar.'
311
311
  : 'No usable registry publication was available for a current lifecycle-policy review.',
312
312
  provenance: 'Separately attributed RDAP and WHOIS lifecycle publications',
313
313
  },
@@ -316,9 +316,9 @@ function registryPolicyChecks(availability, lifecycle, publications) {
316
316
  label: locks.client === true || locks.server === true ? 'Confirm observed transfer constraints' : 'Confirm transfer and change-of-control requirements',
317
317
  state: publicationAvailable ? 'review' : 'unavailable',
318
318
  detail: locks.client === true || locks.server === true
319
- ? 'Recognized client or server lock evidence was observed. Confirm authorization codes, lock removal, waiting periods, identity checks, and any registrar change-of-control process.'
319
+ ? 'Recognised client or server lock evidence was observed. Confirm authorisation codes, lock removal, waiting periods, identity checks, and any registrar change-of-control process.'
320
320
  : publicationAvailable
321
- ? 'No recognized lock was retained, but that does not establish transfer eligibility. Confirm authorization, waiting periods, identity checks, and registrar requirements.'
321
+ ? 'No recognised lock was retained, but that does not establish transfer eligibility. Confirm authorisation, waiting periods, identity checks, and registrar requirements.'
322
322
  : 'No usable status publication was available to describe current transfer constraints.',
323
323
  provenance: 'EPP status interpretation and manual policy requirements',
324
324
  },
@@ -383,7 +383,7 @@ export function buildAcquisitionDueDiligence(input) {
383
383
  policyChecks,
384
384
  nextSteps,
385
385
  limitations: [
386
- 'This workspace organizes observed evidence; it does not value a domain or establish that it can be acquired.',
386
+ 'This workspace organises observed evidence; it does not value a domain or establish that it can be acquired.',
387
387
  'Expiry dates, EPP statuses, sale signals, and contact routes can change after collection.',
388
388
  'WHOISleuth does not assess legal rights, registry eligibility, registrar terms, escrow, tax, or payment risk.',
389
389
  ],
@@ -5,7 +5,7 @@ import { normalizeDomain } from "./case-model.js";
5
5
  import { normalizePageBaseline } from "./page-baseline.js";
6
6
  import { isInformativeFaviconHash } from "./utils.js";
7
7
  export const BRAND_PROFILE_SCHEMA = 'whoisleuth.brand-profiles';
8
- export const BRAND_PROFILE_SCHEMA_VERSION = 5;
8
+ export const BRAND_PROFILE_SCHEMA_VERSION = 6;
9
9
  export const MAX_PROFILES = 100;
10
10
  export const MAX_PROFILE_VALUES = 200;
11
11
  export const MAX_PROFILE_VALUE_INPUTS = MAX_PROFILE_VALUES * 4;
@@ -22,6 +22,8 @@ export const MAX_PROTECTION_ATTESTATIONS = 6;
22
22
  export const MAX_DESIRED_POSTURE_BASELINES = 20;
23
23
  export const MAX_DESIRED_POSTURE_RECORDS = 32;
24
24
  export const MAX_DESIRED_POSTURE_SUPPRESSIONS = 12;
25
+ export const MAX_DESIRED_POSTURE_OBSERVATIONS = 12;
26
+ export const MAX_DESIRED_POSTURE_CHANGE_WINDOWS = 8;
25
27
  const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
26
28
  const SHA256_RE = /^[a-f0-9]{64}$/i;
27
29
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
@@ -203,6 +205,35 @@ function normalizeDesiredPostureObservation(value) {
203
205
  }
204
206
  return checks.length ? { observedAt, checks } : null;
205
207
  }
208
+ function normalizeDesiredPostureObservationHistory(value, previous) {
209
+ const candidates = Array.isArray(value) ? value : previous ? [previous] : [];
210
+ const byTime = new Map();
211
+ for (const item of candidates.slice(0, MAX_DESIRED_POSTURE_OBSERVATIONS * 4)) {
212
+ const normalized = normalizeDesiredPostureObservation(item);
213
+ if (normalized)
214
+ byTime.set(normalized.observedAt, normalized);
215
+ }
216
+ return [...byTime.values()]
217
+ .sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt))
218
+ .slice(-MAX_DESIRED_POSTURE_OBSERVATIONS);
219
+ }
220
+ function normalizeDesiredPostureChangeWindows(value) {
221
+ if (!Array.isArray(value))
222
+ return [];
223
+ const output = [];
224
+ for (const item of value.slice(0, MAX_DESIRED_POSTURE_CHANGE_WINDOWS * 4)) {
225
+ const candidate = record(item);
226
+ const startsAt = timestamp(candidate.startsAt, null);
227
+ const endsAt = timestamp(candidate.endsAt, null);
228
+ const summary = boundedText(candidate.summary, 300);
229
+ if (!startsAt || !endsAt || Date.parse(endsAt) <= Date.parse(startsAt) || !summary)
230
+ continue;
231
+ output.push({ startsAt, endsAt, summary });
232
+ if (output.length >= MAX_DESIRED_POSTURE_CHANGE_WINDOWS)
233
+ break;
234
+ }
235
+ return output.sort((left, right) => Date.parse(left.startsAt) - Date.parse(right.startsAt));
236
+ }
206
237
  function normalizeTlsSanPatterns(value) {
207
238
  if (!Array.isArray(value))
208
239
  return [];
@@ -250,6 +281,8 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
250
281
  break;
251
282
  }
252
283
  seen.add(domain);
284
+ const previousObservation = normalizeDesiredPostureObservation(candidate.previousObservation);
285
+ const observationHistory = normalizeDesiredPostureObservationHistory(candidate.observationHistory, previousObservation);
253
286
  output.push({
254
287
  version: 1,
255
288
  domain,
@@ -266,9 +299,18 @@ export function normalizeDesiredPostureBaselines(value, officialDomains, fallbac
266
299
  ? candidate.registrarLock
267
300
  : 'unconfigured',
268
301
  renewalReviewAt: timestamp(candidate.renewalReviewAt, null),
302
+ zoneIntent: ['active_service', 'defensive_registration', 'no_service', 'parked', 'redirect_only'].includes(String(candidate.zoneIntent))
303
+ ? candidate.zoneIntent
304
+ : 'unconfigured',
305
+ lifecycle: ['active', 'change_planned', 'retiring', 'retired'].includes(String(candidate.lifecycle))
306
+ ? candidate.lifecycle
307
+ : 'active',
308
+ recoveryDependency: boundedText(candidate.recoveryDependency, 200),
309
+ approvedChangeWindows: normalizeDesiredPostureChangeWindows(candidate.approvedChangeWindows),
269
310
  suppressions,
270
311
  note: boundedText(candidate.note, MAX_PROFILE_TEXT_LENGTH),
271
- previousObservation: normalizeDesiredPostureObservation(candidate.previousObservation),
312
+ previousObservation: observationHistory.at(-1) ?? previousObservation,
313
+ observationHistory,
272
314
  updatedAt: timestamp(candidate.updatedAt, fallback),
273
315
  });
274
316
  if (output.length >= MAX_DESIRED_POSTURE_BASELINES)
@@ -376,8 +418,8 @@ export function mergeBrandProfiles(localRaw, importedRaw, options = {}) {
376
418
  if (importedVersion !== null && importedVersion > BRAND_PROFILE_SCHEMA_VERSION) {
377
419
  throw new Error(`This Brand Profile file uses newer schema ${importedVersion}. Update the app before importing it.`);
378
420
  }
379
- if (![2, 3, 4, BRAND_PROFILE_SCHEMA_VERSION].includes(importedVersion ?? 0)) {
380
- throw new Error(`Expected a WHOISleuth Brand Profile export using schema 2, 3, 4, or ${BRAND_PROFILE_SCHEMA_VERSION}.`);
421
+ if (![2, 3, 4, 5, BRAND_PROFILE_SCHEMA_VERSION].includes(importedVersion ?? 0)) {
422
+ throw new Error(`Expected a WHOISleuth Brand Profile export using schema 2, 3, 4, 5, or ${BRAND_PROFILE_SCHEMA_VERSION}.`);
381
423
  }
382
424
  const local = normalizeBrandProfileStore(localRaw).profiles;
383
425
  const byName = new Map(local.map((profile) => [profile.name.toLowerCase(), profile]));
@@ -168,32 +168,32 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
168
168
  .some((value) => ['conflict', 'conflicting'].includes(value.toLowerCase()));
169
169
  const rows = [
170
170
  row('registration', 'registration', 'Registration', left.availability, right.availability, 'Authority-aware compact availability state', registry, [], { conflicting: registrationConflicting }),
171
- row('registrar', 'registration', 'Registrar', left.registrar, right.registrar, 'Normalized registrar display identity', registry),
171
+ row('registrar', 'registration', 'Registrar', left.registrar, right.registrar, 'Normalised registrar display identity', registry),
172
172
  row('created', 'lifecycle', 'Created', left.createdDate, right.createdDate, 'Source-reported lifecycle date', registry),
173
173
  row('expires', 'lifecycle', 'Expires', left.expiryDate, right.expiryDate, 'Source-reported lifecycle date', registry),
174
- row('nameservers', 'infrastructure', 'Nameservers', left.nameservers, right.nameservers, 'Normalized exact set comparison', registry),
175
- row('dns-a', 'dns', 'A records', dnsValues(left, 'a'), dnsValues(right, 'a'), 'Normalized exact retained DNS answer set', dns),
176
- row('dns-aaaa', 'dns', 'AAAA records', dnsValues(left, 'aaaa'), dnsValues(right, 'aaaa'), 'Normalized exact retained DNS answer set', dns),
177
- row('dns-cname', 'dns', 'CNAME records', dnsValues(left, 'cname'), dnsValues(right, 'cname'), 'Normalized exact retained DNS answer set', dns),
178
- row('dns-caa', 'dns', 'CAA records', dnsValues(left, 'caa'), dnsValues(right, 'caa'), 'Normalized exact retained DNS answer set', dns),
174
+ row('nameservers', 'infrastructure', 'Nameservers', left.nameservers, right.nameservers, 'Normalised exact set comparison', registry),
175
+ row('dns-a', 'dns', 'A records', dnsValues(left, 'a'), dnsValues(right, 'a'), 'Normalised exact retained DNS answer set', dns),
176
+ row('dns-aaaa', 'dns', 'AAAA records', dnsValues(left, 'aaaa'), dnsValues(right, 'aaaa'), 'Normalised exact retained DNS answer set', dns),
177
+ row('dns-cname', 'dns', 'CNAME records', dnsValues(left, 'cname'), dnsValues(right, 'cname'), 'Normalised exact retained DNS answer set', dns),
178
+ row('dns-caa', 'dns', 'CAA records', dnsValues(left, 'caa'), dnsValues(right, 'caa'), 'Normalised exact retained DNS answer set', dns),
179
179
  row('dnssec', 'dns', 'DNSSEC state', left.dnssec, right.dnssec, 'Separately attributed compact DNSSEC observation', dns),
180
- row('ip-addresses', 'infrastructure', 'Observed IP addresses', left.relationship.ipAddresses, right.relationship.ipAddresses, 'Normalized exact set comparison', dns, ['Shared hosting and CDNs are common and do not prove common control.']),
180
+ row('ip-addresses', 'infrastructure', 'Observed IP addresses', left.relationship.ipAddresses, right.relationship.ipAddresses, 'Normalised exact set comparison', dns, ['Shared hosting and CDNs are common and do not prove common control.']),
181
181
  row('tls-source', 'certificate', 'TLS collection', tls.leftSourceState, tls.rightSourceState, 'Retained compact source-state comparison', tls),
182
182
  row('certificate', 'certificate', 'Leaf certificate fingerprint', left.relationship.certificateFingerprint, right.relationship.certificateFingerprint, 'Exact SHA-256 comparison', tls, ['Multi-domain certificates and managed hosting are common.']),
183
- row('tls-issuer', 'certificate', 'TLS issuer label', left.comparisonEvidence?.tls.issuerLabel ?? null, right.comparisonEvidence?.tls.issuerLabel ?? null, 'Normalized retained issuer-label comparison', compactTls, ['Issuer labels are certificate metadata and do not establish common ownership or control.']),
183
+ row('tls-issuer', 'certificate', 'TLS issuer label', left.comparisonEvidence?.tls.issuerLabel ?? null, right.comparisonEvidence?.tls.issuerLabel ?? null, 'Normalised retained issuer-label comparison', compactTls, ['Issuer labels are certificate metadata and do not establish common ownership or control.']),
184
184
  row('tls-spki', 'certificate', 'TLS public-key fingerprint', left.comparisonEvidence?.tls.spkiSha256 ?? null, right.comparisonEvidence?.tls.spkiSha256 ?? null, 'Exact SPKI SHA-256 comparison', compactTls, ['Public-key reuse can be an investigative lead but does not establish ownership, intent, safety, or maliciousness.']),
185
185
  row('mail', 'mail', 'MX observed', left.hasMx, right.hasMx, 'Compact Deep DNS observation', dns),
186
186
  row('null-mx', 'mail', 'Null MX observed', left.hasNullMx, right.hasNullMx, 'Compact Deep DNS observation', dns),
187
187
  row('spf', 'mail', 'SPF observed', left.hasSpf, right.hasSpf, 'Compact Deep DNS observation', dns),
188
188
  row('dmarc', 'mail', 'DMARC observed', left.hasDmarc, right.hasDmarc, 'Compact Deep DNS observation', dns),
189
189
  row('website', 'web', 'Website activity', left.activityStatus, right.activityStatus, 'Bounded homepage observation', http),
190
- row('page-title', 'identity', 'Page title', left.pageTitle, right.pageTitle, 'Normalized bounded title equality', http),
190
+ row('page-title', 'identity', 'Page title', left.pageTitle, right.pageTitle, 'Normalised bounded title equality', http),
191
191
  row('favicon', 'identity', 'Favicon fingerprint', left.faviconHash, right.faviconHash, 'Exact SHA-256 comparison', http),
192
- row('tracking', 'identity', 'Tracking identifiers', left.relationship.trackingIdentifiers, right.relationship.trackingIdentifiers, 'Normalized exact set comparison', http, ['Shared identifiers are an investigative lead, not proof of ownership.']),
192
+ row('tracking', 'identity', 'Tracking identifiers', left.relationship.trackingIdentifiers, right.relationship.trackingIdentifiers, 'Normalised exact set comparison', http, ['Shared identifiers are an investigative lead, not proof of ownership.']),
193
193
  row('password-field', 'identity', 'Password field', left.hasPasswordField, right.hasPasswordField, 'Bounded static form observation', http),
194
194
  row('phishing-language', 'identity', 'Phishing-language indicator', left.phishingLanguageMatch, right.phishingLanguageMatch, 'Bounded explainable page-language signal', http, ['A wording match is a review lead, not proof of malicious intent.']),
195
195
  row('official-assets', 'identity', 'Official asset reuse', left.reusesOfficialAssets, right.reusesOfficialAssets, 'Configured Brand Profile comparison', http),
196
- row('technology', 'technology', 'Technology identifiers', left.comparisonEvidence?.technology.ids ?? [], right.comparisonEvidence?.technology.ids ?? [], 'Normalized exact set comparison of at most 12 curated identifiers', compactTechnology, ['An unmatched identifier is not evidence that a technology is absent. Shared technologies do not establish common ownership or control.']),
196
+ row('technology', 'technology', 'Technology identifiers', left.comparisonEvidence?.technology.ids ?? [], right.comparisonEvidence?.technology.ids ?? [], 'Normalised exact set comparison of at most 12 curated identifiers', compactTechnology, ['An unmatched identifier is not evidence that a technology is absent. Shared technologies do not establish common ownership or control.']),
197
197
  row('source-health', 'source', 'Recorded source coverage', sourceSummary(left), sourceSummary(right), 'Source-by-source compact state comparison', source, ['A source-state difference can reflect collection conditions rather than a domain change.']),
198
198
  ];
199
199
  const counts = {
@@ -216,7 +216,7 @@ export function buildBulkDomainComparison(leftRaw, rightRaw, observedAtRaw = nul
216
216
  counts,
217
217
  limitations: options.allowSameDomain
218
218
  ? [
219
- 'This compares normalized evidence already present in two saved observations for the same domain and makes no new request.',
219
+ 'This compares normalised evidence already present in two saved observations for the same domain and makes no new request.',
220
220
  'Missing, unavailable, and differently collected evidence remain distinct from an observed difference.',
221
221
  'An observed difference can reflect changed collection conditions and does not by itself establish current state, ownership, intent, safety, or maliciousness.',
222
222
  ]
@@ -169,7 +169,7 @@ export function buildBulkMailExposureReport(rowsRaw, options = {}) {
169
169
  limitations: [
170
170
  'This review uses compact Bulk evidence already collected and makes no additional request.',
171
171
  'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
172
- 'SMTP delivery, mailbox existence, catch-all behavior, and message acceptance were not tested.',
172
+ 'SMTP delivery, mailbox existence, catch-all behaviour, and message acceptance were not tested.',
173
173
  ],
174
174
  };
175
175
  }
@@ -4,8 +4,8 @@
4
4
  import { normalizeDomain } from "./case-model.js";
5
5
  import { normalizeCaaCritical } from "./dns-record-normalization.js";
6
6
  export const BULK_SESSION_SCHEMA = 'whoisleuth.bulk-sessions';
7
- export const BULK_SESSION_SCHEMA_VERSION = 2;
8
- export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = Object.freeze([1, BULK_SESSION_SCHEMA_VERSION]);
7
+ export const BULK_SESSION_SCHEMA_VERSION = 3;
8
+ export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = Object.freeze([1, 2, BULK_SESSION_SCHEMA_VERSION]);
9
9
  export const MAX_BULK_SESSIONS = 10;
10
10
  export const MAX_BULK_SESSION_ROWS = 2_000;
11
11
  export const MAX_BULK_SESSION_STORE_BYTES = 4 * 1024 * 1024;
@@ -231,6 +231,7 @@ export function normalizeBulkSessionResult(value) {
231
231
  scanDepth: scanDepth,
232
232
  createdDate: boundedText(item.createdDate, 64) || null,
233
233
  expiryDate: boundedText(item.expiryDate, 64) || null,
234
+ privacyProtected: nullableBoolean(item.privacyProtected),
234
235
  nameservers: boundedStrings(item.nameservers, 20),
235
236
  hasMx: nullableBoolean(item.hasMx),
236
237
  hasNullMx: nullableBoolean(item.hasNullMx),
@@ -246,7 +247,11 @@ export function normalizeBulkSessionResult(value) {
246
247
  hasPasswordField: item.hasPasswordField === true,
247
248
  hasExternalFormAction: nullableBoolean(item.hasExternalFormAction),
248
249
  phishingLanguageMatch: boundedText(item.phishingLanguageMatch, 300) || null,
250
+ idnReferenceMatch: nullableBoolean(item.idnReferenceMatch),
251
+ pageBaselineMatch: nullableBoolean(item.pageBaselineMatch),
252
+ hasActiveBrandProfile: nullableBoolean(item.hasActiveBrandProfile),
249
253
  riskModelVersion: nullableVersion(item.riskModelVersion),
254
+ opportunityModelVersion: nullableVersion(item.opportunityModelVersion),
250
255
  riskFactors: normalizeRiskFactors(item.riskFactors),
251
256
  dns: normalizeDns(item.dns),
252
257
  dnssec: boundedText(item.dnssec, 40) || null,
@@ -1,7 +1,7 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
3
  import { normalizeHttpSummary } from "./http-summary.js";
4
- import { normalizeRiskModelVersion } from "./scoring.js";
4
+ import { normalizeOpportunityModelVersion, normalizeRiskModelVersion } from "./scoring.js";
5
5
  import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
6
6
  import { CONCLUSIVE_AVAILABILITY, DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_RANK, EVIDENCE_SOURCE_SET, REGISTERED_LIKE, hashString, isoOrNull, objectRecord, safeId, } from "./case-record-core.js";
7
7
  function evidenceString(value, max = MAX_EVIDENCE_STRING_LENGTH) {
@@ -97,6 +97,7 @@ const DEEP_SIGNAL_FIELDS = [
97
97
  'httpSummaryVersion', 'httpEvidenceStatus', 'httpFinalOrigin', 'httpResponseStatus', 'httpTransportSecurity', 'httpRedirectCount',
98
98
  'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
99
99
  'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
100
+ 'pageBaselineMatch',
100
101
  ];
101
102
  // Ordered list of the fields that make up a snapshot's *material* identity -
102
103
  // everything except capture timestamps, snapshot id and source. `scanDepth` is
@@ -104,7 +105,7 @@ const DEEP_SIGNAL_FIELDS = [
104
105
  // another. Deterministic ordering here is what makes the fingerprint stable.
105
106
  const MATERIAL_FIELD_ORDER = [
106
107
  'scanDepth',
107
- 'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityScore',
108
+ 'availability', 'confidence', 'riskModelVersion', 'riskScore', 'opportunityModelVersion', 'opportunityScore',
108
109
  'riskFactors', 'opportunityFactors',
109
110
  'registrar', 'createdDate', 'expiryDate', 'nameservers',
110
111
  'hasMx', 'hasSpf', 'hasDmarc',
@@ -112,6 +113,7 @@ const MATERIAL_FIELD_ORDER = [
112
113
  'httpSummaryVersion', 'httpEvidenceStatus', 'httpFinalOrigin', 'httpResponseStatus', 'httpTransportSecurity', 'httpRedirectCount',
113
114
  'httpCrossOriginRedirect', 'httpHttpsDowngrade', 'httpContentType', 'httpSecurityHeaders',
114
115
  'faviconMatch', 'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch',
116
+ 'privacyProtected', 'idnReferenceMatch', 'pageBaselineMatch', 'hasActiveBrandProfile',
115
117
  'mutationTypes',
116
118
  ];
117
119
  // The canonical, comparison-safe value of a material field. Registrar casing,
@@ -203,6 +205,7 @@ function buildSnapshot(raw, options) {
203
205
  confidence: evidenceString(record.confidence),
204
206
  riskModelVersion: normalizeRiskModelVersion(record.riskModelVersion),
205
207
  riskScore: clampScore(record.riskScore),
208
+ opportunityModelVersion: normalizeOpportunityModelVersion(record.opportunityModelVersion),
206
209
  opportunityScore: clampScore(record.opportunityScore),
207
210
  riskFactors: normalizeFactors(record.riskFactors),
208
211
  opportunityFactors: normalizeFactors(record.opportunityFactors),
@@ -232,12 +235,18 @@ function buildSnapshot(raw, options) {
232
235
  hasPasswordField: boolOrNull(record.hasPasswordField),
233
236
  hasExternalFormAction: boolOrNull(record.hasExternalFormAction),
234
237
  phishingLanguageMatch: evidenceString(record.phishingLanguageMatch),
238
+ privacyProtected: boolOrNull(record.privacyProtected),
239
+ idnReferenceMatch: boolOrNull(record.idnReferenceMatch),
240
+ pageBaselineMatch: boolOrNull(record.pageBaselineMatch),
241
+ hasActiveBrandProfile: boolOrNull(record.hasActiveBrandProfile),
235
242
  mutationTypes: normalizeMutationList(record.mutationTypes),
236
243
  };
237
244
  // A version without an actual risk assessment is orphaned metadata. Drop it
238
245
  // so it cannot make otherwise-identical evidence look materially different.
239
246
  if (fields.riskScore === null && fields.riskFactors.length === 0)
240
247
  fields.riskModelVersion = null;
248
+ if (fields.opportunityScore === null && fields.opportunityFactors.length === 0)
249
+ fields.opportunityModelVersion = null;
241
250
  // A fast capture never evaluates the deep signals, so any value supplied for
242
251
  // them (e.g. a profile's default `false`) is discarded as unevaluated.
243
252
  if (scanDepth === 'fast') {
@@ -264,6 +273,7 @@ function buildSnapshot(raw, options) {
264
273
  hasPasswordField: null,
265
274
  hasExternalFormAction: null,
266
275
  phishingLanguageMatch: null,
276
+ pageBaselineMatch: null,
267
277
  });
268
278
  }
269
279
  if (!hasMaterialEvidence(fields))
@@ -391,8 +401,8 @@ const COMPARE_FIELDS = [
391
401
  { field: 'confidence', label: 'Confidence', type: 'token' },
392
402
  { field: 'riskScore', label: 'Risk score', type: 'score', depthGate: 'comparable', modelGate: 'risk', direction: 'risk' },
393
403
  { field: 'riskFactors', label: 'Risk factors', type: 'factors', depthGate: 'comparable', modelGate: 'risk' },
394
- { field: 'opportunityScore', label: 'Opportunity score', type: 'score' },
395
- { field: 'opportunityFactors', label: 'Opportunity factors', type: 'factors' },
404
+ { field: 'opportunityScore', label: 'Opportunity score', type: 'score', modelGate: 'opportunity' },
405
+ { field: 'opportunityFactors', label: 'Opportunity factors', type: 'factors', modelGate: 'opportunity' },
396
406
  { field: 'registrar', label: 'Registrar', type: 'registrar' },
397
407
  { field: 'createdDate', label: 'Creation date', type: 'date' },
398
408
  { field: 'expiryDate', label: 'Expiry date', type: 'date' },
@@ -428,6 +438,11 @@ function riskModelComparable(previous, current) {
428
438
  const after = normalizeRiskModelVersion(current?.riskModelVersion);
429
439
  return before !== null && before === after;
430
440
  }
441
+ function opportunityModelComparable(previous, current) {
442
+ const before = normalizeOpportunityModelVersion(previous?.opportunityModelVersion);
443
+ const after = normalizeOpportunityModelVersion(current?.opportunityModelVersion);
444
+ return before !== null && before === after;
445
+ }
431
446
  function valuesMateriallyEqual(field, previous, current) {
432
447
  return JSON.stringify(materialValue(field, previous)) === JSON.stringify(materialValue(field, current));
433
448
  }
@@ -448,6 +463,10 @@ export function caseEvidenceIncomparableReasons(previous, current) {
448
463
  || previous.riskFactors.length > 0 || current.riskFactors.length > 0;
449
464
  if (hasRiskEvidence && !riskModelComparable(previous, current))
450
465
  reasons.push('risk-model');
466
+ const hasOpportunityEvidence = previous.opportunityScore !== null || current.opportunityScore !== null
467
+ || previous.opportunityFactors.length > 0 || current.opportunityFactors.length > 0;
468
+ if (hasOpportunityEvidence && !opportunityModelComparable(previous, current))
469
+ reasons.push('opportunity-model');
451
470
  if (!depthComparable(previous.scanDepth, current.scanDepth)) {
452
471
  const deepOnlyChanged = DEEP_SIGNAL_FIELDS.some((field) => !valuesMateriallyEqual(field, previous, current));
453
472
  const comparableRiskChanged = riskModelComparable(previous, current)
@@ -623,6 +642,7 @@ export function compareCaseEvidence(previous, current) {
623
642
  const bothDeep = previous.scanDepth === 'deep' && current.scanDepth === 'deep';
624
643
  const comparableDepth = depthComparable(previous.scanDepth, current.scanDepth);
625
644
  const comparableRiskModel = riskModelComparable(previous, current);
645
+ const comparableOpportunityModel = opportunityModelComparable(previous, current);
626
646
  const changes = [];
627
647
  for (const spec of COMPARE_FIELDS) {
628
648
  if (spec.depthGate === 'both-deep' && !bothDeep)
@@ -631,6 +651,8 @@ export function compareCaseEvidence(previous, current) {
631
651
  continue;
632
652
  if (spec.modelGate === 'risk' && !comparableRiskModel)
633
653
  continue;
654
+ if (spec.modelGate === 'opportunity' && !comparableOpportunityModel)
655
+ continue;
634
656
  const result = compareField(spec, previous[spec.field], current[spec.field]);
635
657
  if (result) {
636
658
  changes.push({ field: spec.field, label: spec.label, before: result.before, after: result.after, tone: result.tone });
@@ -1,4 +1,4 @@
1
- import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, isoOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeTags, objectRecord, safeId, } from "./case-record-model.js";
1
+ import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, isoOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, } from "./case-record-model.js";
2
2
  import { mergeCaseActions, mergeCaseAssertions, mergeCaseDecisions, mergeCaseEvidencePins, mergeCaseManualTrail, mergeCaseSightings, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, } from "./case-response-model.js";
3
3
  const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
4
4
  const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
@@ -97,6 +97,9 @@ function extractImportPatch(raw) {
97
97
  rawId: typeof record.id === 'string' ? record.id : null,
98
98
  status: importScalar(record.status, STATUS_VALUES),
99
99
  disposition: importScalar(record.disposition, DISPOSITION_VALUES),
100
+ reviewReasonCode: Object.hasOwn(record, 'reviewReasonCode')
101
+ ? normalizeReviewReasonCode(record.reviewReasonCode)
102
+ : undefined,
100
103
  source: importScalar(record.source, SOURCE_VALUES),
101
104
  evidenceHistory: normalizeEvidenceHistory(rawEvidence, { source: 'import', fallback: importFallback }),
102
105
  evidencePins,
@@ -139,6 +142,7 @@ function caseFromPatch(patch, now) {
139
142
  domain: patch.domain,
140
143
  status: patch.status ?? DEFAULT_STATUS,
141
144
  disposition: patch.disposition ?? DEFAULT_DISPOSITION,
145
+ reviewReasonCode: patch.reviewReasonCode ?? null,
142
146
  tags: patch.tags,
143
147
  notes: patch.notes,
144
148
  source: patch.source ?? DEFAULT_SOURCE,
@@ -171,6 +175,7 @@ function applyImportPatch(local, patch) {
171
175
  ...local,
172
176
  status: patch.status !== undefined && importNewer ? patch.status : local.status,
173
177
  disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
178
+ reviewReasonCode: patch.reviewReasonCode !== undefined && importNewer ? patch.reviewReasonCode : local.reviewReasonCode ?? null,
174
179
  source: patch.source !== undefined && importNewer ? patch.source : local.source,
175
180
  evidenceHistory: mergeEvidenceHistories(local.evidenceHistory, patch.evidenceHistory),
176
181
  evidencePins,
@@ -1,5 +1,6 @@
1
- export const CASE_SCHEMA_VERSION = 9;
2
- export const CASE_IMPORT_VERSIONS = [3, 4, 5, 6, 7, 8, CASE_SCHEMA_VERSION];
1
+ import { ANALYST_REVIEW_REASONS } from "../../../../lib/analyst-taxonomy.mjs";
2
+ export const CASE_SCHEMA_VERSION = 10;
3
+ export const CASE_IMPORT_VERSIONS = [3, 4, 5, 6, 7, 8, 9, CASE_SCHEMA_VERSION];
3
4
  export const MAX_CASES = 500;
4
5
  export const MAX_NOTES_PER_CASE = 50;
5
6
  export const MAX_NOTE_LENGTH = 2000;
@@ -31,6 +32,7 @@ export const CASE_DISPOSITIONS = [
31
32
  { value: 'expected', label: 'Expected' },
32
33
  { value: 'closed_no_action', label: 'Closed without action' },
33
34
  ];
35
+ export const CASE_REVIEW_REASONS = ANALYST_REVIEW_REASONS.map((item) => ({ ...item }));
34
36
  export const CASE_SOURCES = [
35
37
  { value: 'lookup', label: 'Lookup' },
36
38
  { value: 'bulk', label: 'Bulk' },
@@ -1,6 +1,6 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
3
+ import { CASE_DISPOSITIONS, CASE_REVIEW_REASONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
4
4
  // Forward-version policy (two distinct guarantees):
5
5
  // - A locally-stored envelope that declares a version greater than this is
6
6
  // still read best-effort on load (known fields kept, unknown dropped), but
@@ -26,6 +26,7 @@ export const EVIDENCE_SOURCE_RANK = {
26
26
  };
27
27
  const STATUS_VALUES = new Set(CASE_STATUSES.map((item) => item.value));
28
28
  const DISPOSITION_VALUES = new Set(CASE_DISPOSITIONS.map((item) => item.value));
29
+ const REVIEW_REASON_VALUES = new Set(CASE_REVIEW_REASONS.map((item) => item.value).filter(Boolean));
29
30
  const SOURCE_VALUES = new Set(CASE_SOURCES.map((item) => item.value));
30
31
  const STATUS_LABELS = Object.fromEntries(CASE_STATUSES.map((item) => [item.value, item.label]));
31
32
  const DISPOSITION_LABELS = Object.fromEntries(CASE_DISPOSITIONS.map((item) => [item.value, item.label]));
@@ -135,6 +136,9 @@ export function normalizeStatus(value) {
135
136
  export function normalizeDisposition(value) {
136
137
  return typeof value === 'string' && DISPOSITION_VALUES.has(value) ? value : DEFAULT_DISPOSITION;
137
138
  }
139
+ export function normalizeReviewReasonCode(value) {
140
+ return typeof value === 'string' && REVIEW_REASON_VALUES.has(value) ? value : null;
141
+ }
138
142
  export function normalizeSource(value) {
139
143
  return typeof value === 'string' && SOURCE_VALUES.has(value) ? value : DEFAULT_SOURCE;
140
144
  }
@@ -2,7 +2,7 @@
2
2
  // record normalization, and analyst updates.
3
3
  import { appendCaseAction, appendCaseAssertion, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseSighting, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.js";
4
4
  import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_CASES, MAX_CASE_IMPORT_BYTES, MAX_CASE_STORE_BYTES, MAX_DOMAIN_LENGTH, MAX_EVIDENCE_CHANGES, MAX_EVIDENCE_DETAIL_LENGTH, MAX_EVIDENCE_FACTORS, MAX_EVIDENCE_MUTATIONS, MAX_EVIDENCE_NAMESERVERS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE, MAX_EVIDENCE_STRING_LENGTH, MAX_EVIDENCE_TITLE_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
5
- import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, deterministicId, isoOrNow, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.js";
5
+ import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, deterministicId, isoOrNow, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.js";
6
6
  import { normalizeEvidenceHistory, } from "./case-evidence-model.js";
7
7
  // ---------------------------------------------------------------------------
8
8
  // Case normalization
@@ -49,6 +49,7 @@ export function normalizeCase(raw, existing, nowIso) {
49
49
  domain,
50
50
  status: normalizeStatus(record.status),
51
51
  disposition: normalizeDisposition(record.disposition),
52
+ reviewReasonCode: normalizeReviewReasonCode(record.reviewReasonCode),
52
53
  tags: normalizeTags(record.tags),
53
54
  notes: normalizeNotes(record.notes, now),
54
55
  source: normalizeSource(record.source),
@@ -86,6 +87,7 @@ export function createCase(input, nowIso) {
86
87
  domain,
87
88
  status: normalizeStatus(input.status),
88
89
  disposition: normalizeDisposition(input.disposition),
90
+ reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
89
91
  tags: normalizeTags(input.tags),
90
92
  notes: noteBody ? [{ id: makeId(), body: noteBody, createdAt: now }] : [],
91
93
  source,
@@ -206,6 +208,9 @@ export function updateCase(cases, id, patch, nowIso) {
206
208
  ...current,
207
209
  status: patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
208
210
  disposition: patch.disposition !== undefined ? normalizeDisposition(patch.disposition) : current.disposition,
211
+ reviewReasonCode: patch.reviewReasonCode !== undefined
212
+ ? normalizeReviewReasonCode(patch.reviewReasonCode)
213
+ : current.reviewReasonCode ?? null,
209
214
  tags: patch.tags !== undefined ? normalizeTags(patch.tags) : current.tags,
210
215
  source,
211
216
  evidenceHistory,