@slicedearth/whoisleuth-cli 2.2.0 → 2.3.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 (77) hide show
  1. package/README.md +2 -1
  2. package/cli/arguments.mjs +389 -1745
  3. package/cli/artifact-validation/case-response.mjs +55 -30
  4. package/cli/artifact-validation/signed-review.mjs +30 -12
  5. package/cli/bulk-checkpoint.mjs +34 -3
  6. package/cli/case-pack.mjs +39 -92
  7. package/cli/command-argument-grammar.mjs +211 -0
  8. package/cli/command-reference.mjs +200 -213
  9. package/cli/completion.mjs +5 -1
  10. package/cli/discovery-observation-snapshot.mjs +16 -3
  11. package/cli/discovery-scan-command-runner.mjs +19 -0
  12. package/cli/discovery-scan.mjs +6 -0
  13. package/cli/domain-control-observations.mjs +1 -1
  14. package/cli/inline-command-families.mjs +1 -0
  15. package/cli/input.mjs +116 -0
  16. package/cli/interchange-report.mjs +14 -0
  17. package/cli/lookalike-calibration.mjs +7 -2
  18. package/cli/lookup-brief.mjs +4 -2
  19. package/cli/lookup-browser-view.mjs +1 -1
  20. package/cli/mail-header-review.mjs +467 -0
  21. package/cli/review-command-runner.mjs +25 -0
  22. package/cli/risk-calibration.mjs +19 -17
  23. package/cli/runner.mjs +7 -1
  24. package/docs/cli-reference.md +8 -1
  25. package/docs/cli.md +12 -1
  26. package/lib/bounded-json.mjs +9 -6
  27. package/lib/dns-convergence-review.mjs +1 -0
  28. package/lib/dns-intelligence.mjs +37 -3
  29. package/lib/dnssec-chain-validation.mjs +6 -0
  30. package/lib/domain-change-packet.mjs +42 -14
  31. package/lib/domain-control-flight-recorder.mjs +4 -1
  32. package/lib/domain-control-manifest.mjs +16 -16
  33. package/lib/domain-posture-parsers.mjs +114 -3
  34. package/lib/domain-posture.mjs +34 -3
  35. package/lib/evidence-export.mjs +60 -11
  36. package/lib/evidence-report.mjs +2 -2
  37. package/lib/html-signals.mjs +39 -33
  38. package/lib/local-geoip-evidence.mjs +19 -3
  39. package/lib/lookup-response.mjs +5 -4
  40. package/lib/lookup-source-progress.mjs +27 -4
  41. package/lib/nameserver-preflight-review.mjs +1 -0
  42. package/lib/rdap-capabilities.mjs +1 -1
  43. package/lib/registry-insights.mjs +35 -22
  44. package/lib/risk-calibration-summary.mjs +1 -3
  45. package/lib/security-txt.mjs +33 -1
  46. package/lib/static-html-analysis.mjs +69 -13
  47. package/lib/website-technology.mjs +79 -11
  48. package/lib/zone-intent-review.mjs +16 -6
  49. package/package.json +1 -1
  50. package/packages/cases/case-migration-model.mjs +71 -22
  51. package/packages/cases/case-record-contracts.mjs +5 -20
  52. package/packages/cases/case-record-core.mjs +10 -21
  53. package/packages/cases/case-record-decisions.mjs +72 -0
  54. package/packages/cases/case-record-model.mjs +1 -0
  55. package/packages/cases/case-record-operations.mjs +6 -4
  56. package/packages/cases/case-record-projection.mjs +218 -0
  57. package/packages/cases/case-report.mjs +11 -6
  58. package/packages/cases/case-response-model.mjs +43 -6
  59. package/packages/cases/case-response-packet.mjs +78 -27
  60. package/packages/cases/case-storage-model.mjs +6 -2
  61. package/packages/comparison/comparison-ledger-bulk.mjs +9 -10
  62. package/packages/comparison/page-similarity.mjs +3 -1
  63. package/packages/contracts/case-portability.mjs +171 -51
  64. package/packages/contracts/cli-command-semantics.mjs +93 -0
  65. package/packages/contracts/risk-calibration.mjs +15 -10
  66. package/packages/contracts/workspace-portability.mjs +44 -7
  67. package/packages/evidence/domain-control-runtime.mjs +46 -6
  68. package/packages/interchange/external-findings-import.mjs +3 -2
  69. package/packages/investigation/lookup-asset-graph.mjs +35 -27
  70. package/packages/workspace/brand-profile-model.mjs +118 -2
  71. package/packages/workspace/bulk-review-model.mjs +13 -7
  72. package/packages/workspace/bulk-session-model.mjs +5 -4
  73. package/packages/workspace/detection-rule-model.mjs +70 -22
  74. package/packages/workspace/relationship-observation-model.mjs +4 -1
  75. package/packages/workspace/website-snapshot-model.mjs +106 -8
  76. package/packages/workspace/workspace-archive.mjs +2 -0
  77. package/cli/evidence-command-arguments.mjs +0 -156
@@ -1,10 +1,11 @@
1
1
  // Pure abuse-evidence packet builder. It creates local review artifacts only:
2
2
  // no network requests, mailto links, submissions, or provider side effects.
3
+ import { caseDispositionSupportsDefensiveResponse } from "./case-record-operations.mjs";
3
4
  import { buildCaseActionOutcomeSummary, buildCaseResponseLifecycleSummary, CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_ASSERTION_STATES, CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, CASE_PIN_COMPLETENESS, CASE_PROVIDER_OUTCOMES, } from "./case-response-model.mjs";
4
5
  import { canonicalArtifactJsonV2, SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
5
6
  import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
6
- import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTIONS, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, MAX_RESPONSE_VALUE_LENGTH, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
7
- export { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, };
7
+ import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTIONS, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, MAX_RESPONSE_VALUE_LENGTH, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
8
+ export { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_ROUTE_STALE_AFTER_DAYS, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, };
8
9
  export const CASE_RESPONSE_PREFLIGHT_EVIDENCE_SCOPE = Object.freeze({
9
10
  version: 1,
10
11
  owner: 'case',
@@ -25,12 +26,14 @@ export const RESPONSE_CONTACT_KINDS = [
25
26
  'registry',
26
27
  'network_hosting',
27
28
  'security_txt',
29
+ 'application_platform',
28
30
  ];
29
31
  export const RESPONSE_PACKET_PROFILE_IDS = [
30
32
  'registrar',
31
33
  'registry',
32
34
  'network_hosting',
33
35
  'security_contact',
36
+ 'application_platform',
34
37
  'browser_blocklist',
35
38
  'internal_soc',
36
39
  ];
@@ -111,6 +114,20 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
111
114
  attachments: ['Reviewed response packet', 'Optional normalised case report'],
112
115
  followUpFields: ['Security reference', 'Triage acknowledgement', 'Remediation outcome'],
113
116
  },
117
+ {
118
+ id: 'application_platform',
119
+ label: 'Platform report review',
120
+ audience: 'Application-platform safety, impersonation, commerce, or rights team',
121
+ subjectPrefix: 'Reviewed platform report',
122
+ requiredContactKind: 'application_platform',
123
+ checklist: ['Confirm the exact account or content URL', 'Choose the route matching the evidence and reporter authority', 'Review the provider notice before sharing identity or rights-owner details'],
124
+ evidenceOrder: ['Incident facts', 'Exact platform URLs', 'Selected observations', 'Rights or authority evidence', 'Action history'],
125
+ includedEvidence: ['Exact URLs', 'Observation time', 'Selected evidence pins', 'Route provenance and review deadline', 'Relevant rights or authority context'],
126
+ excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected personal data', 'Unsupported policy or legal conclusions'],
127
+ redactions: ['Credential-bearing URLs are rejected; review query strings, fragments, tokens, identity documents, and unrelated personal data before sharing'],
128
+ attachments: ['Reviewed response packet', 'Optional normalised case report'],
129
+ followUpFields: ['Provider reference', 'Acknowledgement time', 'Policy response', 'Independent observed effect'],
130
+ },
114
131
  {
115
132
  id: 'browser_blocklist',
116
133
  label: 'Browser or blocklist review',
@@ -142,6 +159,9 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
142
159
  ]);
143
160
  const CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS);
144
161
  const RESPONSE_PROFILE_IDS = new Set(RESPONSE_PACKET_PROFILE_IDS);
162
+ const PRE_PLATFORM_CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS.filter((kind) => kind !== 'application_platform'));
163
+ const PRE_PLATFORM_PROFILE_IDS = new Set(RESPONSE_PACKET_PROFILE_IDS.filter((id) => id !== 'application_platform'));
164
+ const PRE_PLATFORM_ACTION_TYPES = CASE_ACTION_TYPES.filter((type) => type !== 'platform_report');
145
165
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
146
166
  const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
147
167
  function text(value, maximum) {
@@ -158,10 +178,17 @@ function timestamp(value) {
158
178
  const parsed = Date.parse(value);
159
179
  return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
160
180
  }
161
- function normalizeExactUrl(value) {
162
- const candidate = text(value, MAX_EXACT_URL_LENGTH);
181
+ function normalizeExactUrl(value, rejectOverlong) {
182
+ if (typeof value !== 'string' || CONTROL_RE.test(value))
183
+ return null;
184
+ const candidate = value.trim();
163
185
  if (!candidate)
164
186
  return null;
187
+ if (candidate.length > MAX_EXACT_URL_LENGTH) {
188
+ if (rejectOverlong)
189
+ throw new Error(`Each exact HTTP(S) URL is limited to ${MAX_EXACT_URL_LENGTH} characters; shorten or remove the overlong URL before continuing.`);
190
+ return null;
191
+ }
165
192
  try {
166
193
  const parsed = new URL(candidate);
167
194
  if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
@@ -172,7 +199,7 @@ function normalizeExactUrl(value) {
172
199
  return null;
173
200
  }
174
201
  }
175
- function normalizeUrls(value) {
202
+ function normalizeUrls(value, rejectOverlong = false) {
176
203
  const source = Array.isArray(value)
177
204
  ? value
178
205
  : typeof value === 'string'
@@ -180,7 +207,7 @@ function normalizeUrls(value) {
180
207
  : [];
181
208
  const unique = new Set();
182
209
  for (const item of source.slice(0, MAX_ABUSIVE_URLS * 2)) {
183
- const normalized = normalizeExactUrl(item);
210
+ const normalized = normalizeExactUrl(item, rejectOverlong);
184
211
  if (normalized)
185
212
  unique.add(normalized);
186
213
  if (unique.size >= MAX_ABUSIVE_URLS)
@@ -213,14 +240,18 @@ function actionContactKind(type) {
213
240
  return 'network_hosting';
214
241
  if (type === 'security_contact_report')
215
242
  return 'security_txt';
243
+ if (type === 'platform_report')
244
+ return 'application_platform';
216
245
  return 'manual';
217
246
  }
218
- function routeFreshness(observedAt, generatedAt) {
247
+ function routeFreshness(observedAt, reviewAfter, generatedAt) {
219
248
  if (!observedAt)
220
249
  return 'unknown';
221
250
  const routeAge = Date.parse(generatedAt) - Date.parse(observedAt);
251
+ const reviewExpired = reviewAfter !== null && Date.parse(generatedAt) >= Date.parse(reviewAfter);
222
252
  return routeAge < -MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS
223
- || routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000
253
+ || reviewExpired
254
+ || (reviewAfter === null && routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000)
224
255
  ? 'stale'
225
256
  : 'current';
226
257
  }
@@ -282,13 +313,15 @@ function bindPacketRoute(caseRecord, input, generatedAt) {
282
313
  };
283
314
  }
284
315
  const observedAt = timestamp(action.routeObservedAt);
316
+ const reviewAfter = timestamp(action.routeReviewAfter);
285
317
  const recipientRoute = {
286
318
  actionId: action.id,
287
319
  kind,
288
320
  contact: text(action.recipient, 320),
289
321
  source: text(action.contactSource, 120) || 'analyst supplied',
290
322
  observedAt,
291
- freshness: routeFreshness(observedAt, generatedAt),
323
+ reviewAfter,
324
+ freshness: routeFreshness(observedAt, reviewAfter, generatedAt),
292
325
  limitations: normalizeLimitations(action.contactLimitations),
293
326
  };
294
327
  const contacts = kind === 'manual' ? [] : [{
@@ -296,6 +329,7 @@ function bindPacketRoute(caseRecord, input, generatedAt) {
296
329
  contact: recipientRoute.contact,
297
330
  source: recipientRoute.source,
298
331
  observedAt,
332
+ reviewAfter,
299
333
  freshness: recipientRoute.freshness,
300
334
  limitations: [...recipientRoute.limitations],
301
335
  }];
@@ -553,7 +587,7 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
553
587
  const actionSummary = buildCaseActionOutcomeSummary(caseRecord.actions, normalizedGeneratedAt);
554
588
  const retainedPinIds = new Set(caseRecord.evidencePins.map((pin) => pin.id));
555
589
  const evidenceLinkedDecisionCount = caseRecord.decisions.filter((decision) => decision.evidencePinIds.some((evidencePinId) => retainedPinIds.has(evidencePinId))).length;
556
- const responseDisposition = ['suspicious', 'confirmed_abuse'].includes(caseRecord.disposition);
590
+ const responseDisposition = caseDispositionSupportsDefensiveResponse(caseRecord.disposition);
557
591
  const reviewedActionCount = caseRecord.actions.filter((action) => ['reviewed', 'authorised', 'submitted', 'acknowledged', 'terminal'].includes(action.state)).length;
558
592
  const profile = responsePacketProfile(input.profile);
559
593
  const checks = [
@@ -607,6 +641,8 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
607
641
  : 'The browser or blocklist profile requires a manually reviewed submission destination recorded as a Case action.'
608
642
  : `${profile.label} has no fixed external contact-kind requirement.`,
609
643
  },
644
+ // Packet v9 retains its established machine-token wording for immutable
645
+ // fixture compatibility; interactive Case surfaces use the canonical label.
610
646
  {
611
647
  id: 'case_disposition',
612
648
  label: 'Case disposition',
@@ -681,6 +717,7 @@ function normalizeActionHistory(caseRecord, input) {
681
717
  recipient: text(action.recipient, 320),
682
718
  contactSource: text(action.contactSource, 120),
683
719
  routeObservedAt: timestamp(action.routeObservedAt),
720
+ routeReviewAfter: timestamp(action.routeReviewAfter),
684
721
  state: text(action.state, 80),
685
722
  reference: text(action.reference, 500) || null,
686
723
  providerOutcome: text(action.providerOutcome, 80) || null,
@@ -804,7 +841,7 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
804
841
  incident: {
805
842
  category,
806
843
  affectedParty: text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH),
807
- abusiveUrls: normalizeUrls(input.abusiveUrls),
844
+ abusiveUrls: normalizeUrls(input.abusiveUrls, true),
808
845
  observedHarm: text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH),
809
846
  observedAt: timestamp(input.observedAt) || caseRecord.evidenceHistory.at(-1)?.capturedAt || null,
810
847
  },
@@ -955,17 +992,20 @@ export function validateCaseResponseReviewInputs(value) {
955
992
  || !descriptors.version || !Object.hasOwn(descriptors.version, 'value')) {
956
993
  throw new TypeError('Case-response review inputs must not contain accessors.');
957
994
  }
958
- const current = descriptors.version.value === CASE_RESPONSE_REVIEW_INPUTS_VERSION;
959
- const publishedV2 = descriptors.version.value === PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION;
960
- if (descriptors.contract.value !== CASE_RESPONSE_REVIEW_INPUTS_SCHEMA || (!current && !publishedV2)) {
995
+ const version = descriptors.version.value;
996
+ const current = version === CASE_RESPONSE_REVIEW_INPUTS_VERSION;
997
+ const hasActionBinding = version === LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION || current;
998
+ const publishedV2 = version === PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION;
999
+ if (descriptors.contract.value !== CASE_RESPONSE_REVIEW_INPUTS_SCHEMA || (!current && !hasActionBinding && !publishedV2)) {
961
1000
  throw new TypeError('Case-response review inputs contain an unsupported version, shape, or bound.');
962
1001
  }
963
- const source = exactReviewRecord(value, current ? CASE_RESPONSE_REVIEW_INPUT_KEYS : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS, 'Case-response review inputs');
1002
+ const source = exactReviewRecord(value, hasActionBinding ? CASE_RESPONSE_REVIEW_INPUT_KEYS : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS, 'Case-response review inputs');
964
1003
  const profile = exactReviewRecord(source.profile, [
965
1004
  'id', 'label', 'audience', 'subject', 'checklist', 'includedEvidence',
966
1005
  'excludedEvidence', 'redactions',
967
1006
  ], 'Case-response review profile');
968
- if (typeof profile.id !== 'string' || !RESPONSE_PROFILE_IDS.has(profile.id)) {
1007
+ const supportedProfileIds = current ? RESPONSE_PROFILE_IDS : PRE_PLATFORM_PROFILE_IDS;
1008
+ if (typeof profile.id !== 'string' || !supportedProfileIds.has(profile.id)) {
969
1009
  throw new TypeError('Case-response review profile is unsupported.');
970
1010
  }
971
1011
  for (const key of ['label', 'audience', 'subject']) {
@@ -992,9 +1032,10 @@ export function validateCaseResponseReviewInputs(value) {
992
1032
  reviewText(incident.observedAt, 64, 'Case-response observation time', true);
993
1033
  for (const candidate of boundedReviewArray(source.contacts, MAX_RESPONSE_CONTACTS, 'Case-response contacts')) {
994
1034
  const contact = exactReviewRecord(candidate, [
995
- 'kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations',
1035
+ 'kind', 'contact', 'source', 'observedAt', ...(current ? ['reviewAfter'] : []), 'freshness', 'limitations',
996
1036
  ], 'Case-response contact');
997
- if (typeof contact.kind !== 'string' || !CONTACT_KINDS.has(contact.kind)
1037
+ const supportedContactKinds = current ? CONTACT_KINDS : PRE_PLATFORM_CONTACT_KINDS;
1038
+ if (typeof contact.kind !== 'string' || !supportedContactKinds.has(contact.kind)
998
1039
  || typeof contact.freshness !== 'string'
999
1040
  || !['current', 'stale', 'unknown'].includes(contact.freshness)) {
1000
1041
  throw new TypeError('Case-response contact contains an unsupported enum.');
@@ -1002,22 +1043,26 @@ export function validateCaseResponseReviewInputs(value) {
1002
1043
  reviewText(contact.contact, 320, 'Case-response contact value');
1003
1044
  reviewText(contact.source, 120, 'Case-response contact source');
1004
1045
  reviewText(contact.observedAt, 64, 'Case-response contact observation time', true);
1046
+ if (current)
1047
+ reviewText(contact.reviewAfter, 64, 'Case-response contact review deadline', true);
1005
1048
  reviewStrings(contact.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contact limitations');
1006
1049
  }
1007
- if (current && source.recipientRoute !== null) {
1050
+ if (hasActionBinding && source.recipientRoute !== null) {
1008
1051
  const route = exactReviewRecord(source.recipientRoute, [
1009
- 'actionId', 'kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations',
1052
+ 'actionId', 'kind', 'contact', 'source', 'observedAt', ...(current ? ['reviewAfter'] : []), 'freshness', 'limitations',
1010
1053
  ], 'Case-response recipient route');
1011
1054
  reviewText(route.actionId, 64, 'Case-response recipient action id');
1012
- reviewEnum(route.kind, [...RESPONSE_CONTACT_KINDS, 'manual'], 'Case-response recipient kind');
1055
+ reviewEnum(route.kind, [...(current ? RESPONSE_CONTACT_KINDS : [...PRE_PLATFORM_CONTACT_KINDS]), 'manual'], 'Case-response recipient kind');
1013
1056
  reviewText(route.contact, 320, 'Case-response recipient value');
1014
1057
  reviewText(route.source, 120, 'Case-response recipient source');
1015
1058
  reviewText(route.observedAt, 64, 'Case-response recipient observation time', true);
1059
+ if (current)
1060
+ reviewText(route.reviewAfter, 64, 'Case-response recipient review deadline', true);
1016
1061
  reviewEnum(route.freshness, ['current', 'stale', 'unknown'], 'Case-response recipient freshness');
1017
1062
  reviewStrings(route.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response recipient limitations');
1018
1063
  }
1019
1064
  let currentLineageActionIds = null;
1020
- if (current) {
1065
+ if (hasActionBinding) {
1021
1066
  const actionBinding = exactReviewRecord(source.actionBinding, [
1022
1067
  'state', 'selectedActionId', 'lineageActionIds', 'limitations',
1023
1068
  ], 'Case-response action binding');
@@ -1059,7 +1104,7 @@ export function validateCaseResponseReviewInputs(value) {
1059
1104
  const readiness = exactReviewRecord(source.readiness, [
1060
1105
  'profileId', 'rows', 'counts', 'limitations',
1061
1106
  ], 'Case-response readiness');
1062
- if (typeof readiness.profileId !== 'string' || !RESPONSE_PROFILE_IDS.has(readiness.profileId)) {
1107
+ if (typeof readiness.profileId !== 'string' || !supportedProfileIds.has(readiness.profileId)) {
1063
1108
  throw new TypeError('Case-response readiness profile is unsupported.');
1064
1109
  }
1065
1110
  const readinessRows = boundedReviewArray(readiness.rows, RESPONSE_READINESS_ROW_IDS.length, 'Case-response readiness rows');
@@ -1111,7 +1156,8 @@ export function validateCaseResponseReviewInputs(value) {
1111
1156
  for (const candidate of boundedReviewArray(source.escalationHistory, MAX_RESPONSE_ACTION_HISTORY, 'Case-response escalation history')) {
1112
1157
  const action = exactReviewRecord(candidate, [
1113
1158
  'actionId', 'type', 'recipient', 'contactSource', 'state', 'reference',
1114
- ...(current ? ['routeObservedAt'] : []),
1159
+ ...(hasActionBinding ? ['routeObservedAt'] : []),
1160
+ ...(current ? ['routeReviewAfter'] : []),
1115
1161
  'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
1116
1162
  'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
1117
1163
  ], 'Case-response escalation action');
@@ -1122,9 +1168,11 @@ export function validateCaseResponseReviewInputs(value) {
1122
1168
  throw new TypeError('Case-response escalation action identities must be unique.');
1123
1169
  }
1124
1170
  escalationActionIds.push(action.actionId);
1125
- if (current)
1171
+ if (hasActionBinding)
1126
1172
  reviewText(action.routeObservedAt, 64, 'Case-response action route observation time', true);
1127
- reviewEnum(action.type, CASE_ACTION_TYPES, 'Case-response action type');
1173
+ if (current)
1174
+ reviewText(action.routeReviewAfter, 64, 'Case-response action route review deadline', true);
1175
+ reviewEnum(action.type, current ? CASE_ACTION_TYPES : PRE_PLATFORM_ACTION_TYPES, 'Case-response action type');
1128
1176
  reviewEnum(action.state, CASE_ACTION_STATES, 'Case-response action state');
1129
1177
  for (const key of ['reference', 'outcomeDetail', 'originActionId']) {
1130
1178
  reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`, true);
@@ -1240,6 +1288,8 @@ function contactLabel(value) {
1240
1288
  return 'Observed endpoint network registration';
1241
1289
  if (value === 'security_txt')
1242
1290
  return 'security.txt';
1291
+ if (value === 'application_platform')
1292
+ return 'Application platform';
1243
1293
  if (value === 'manual')
1244
1294
  return 'Manual submission';
1245
1295
  return value.charAt(0).toUpperCase() + value.slice(1);
@@ -1247,7 +1297,7 @@ function contactLabel(value) {
1247
1297
  export async function buildCaseResponsePacket(caseRecord, input, generatedAt = new Date().toISOString()) {
1248
1298
  const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH);
1249
1299
  const affectedParty = text(input.affectedParty, MAX_AFFECTED_PARTY_LENGTH);
1250
- const abusiveUrls = normalizeUrls(input.abusiveUrls);
1300
+ const abusiveUrls = normalizeUrls(input.abusiveUrls, true);
1251
1301
  const observedHarm = text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH);
1252
1302
  const latestEvidence = caseRecord.evidenceHistory.at(-1) ?? null;
1253
1303
  const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
@@ -1374,6 +1424,7 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
1374
1424
  `- Contact: ${escapeMarkdown(recipientRoute.contact)}`,
1375
1425
  `- Source: ${escapeMarkdown(recipientRoute.source)}`,
1376
1426
  `- Route observed: ${recipientRoute.observedAt ?? 'Not provided'} (${recipientRoute.freshness})`,
1427
+ `- Route review after: ${recipientRoute.reviewAfter ?? 'Not provided'}`,
1377
1428
  `- Limitations: ${recipientRoute.limitations.length ? recipientRoute.limitations.map(escapeMarkdown).join('; ') : 'None recorded'}`,
1378
1429
  '',
1379
1430
  ]
@@ -1,8 +1,12 @@
1
1
  import { CASE_SCHEMA_VERSION, MAX_CASE_STORE_BYTES, } from "./case-record-model.mjs";
2
2
  import { normalizeCaseStore } from "./case-migration-model.mjs";
3
+ import { projectCaseForDurableWrite } from "./case-record-projection.mjs";
3
4
  /** The exact versioned string persisted by the browser storage adapter. */
4
5
  export function serializeCaseStore(cases) {
5
- return JSON.stringify({ version: CASE_SCHEMA_VERSION, cases });
6
+ return JSON.stringify({
7
+ version: CASE_SCHEMA_VERSION,
8
+ cases: cases.map(projectCaseForDurableWrite),
9
+ });
6
10
  }
7
11
  function byteLength(text) {
8
12
  if (typeof TextEncoder !== 'undefined') {
@@ -88,6 +92,6 @@ export function buildCaseExport(cases, nowIso) {
88
92
  return {
89
93
  version: CASE_SCHEMA_VERSION,
90
94
  exportedAt: nowIso || new Date().toISOString(),
91
- cases: normalizeCaseStore(cases).cases,
95
+ cases: normalizeCaseStore(cases).cases.map(projectCaseForDurableWrite),
92
96
  };
93
97
  }
@@ -1,14 +1,8 @@
1
- import { MAX_BULK_SESSIONS, compareBulkSessions, normalizeBulkSession, } from "../workspace/bulk-session-model.mjs";
1
+ import { MAX_BULK_SESSIONS, areBulkRiskScoresComparable, compareBulkSessions, normalizeBulkSession, } from "../workspace/bulk-session-model.mjs";
2
2
  import { MAX_COMPARISON_LEDGER_BULK_PAIRS, boundedComparisonLedgerId, comparisonLedgerCollector, comparisonLedgerInputArray, comparisonLedgerRecord, comparisonLedgerValuePresent, comparisonLedgerValueState, makeComparisonLedgerCandidate, stableComparisonLedgerId, } from "./comparison-ledger-contract.mjs";
3
- function bulkCompleteness(session) {
4
- return session.state === 'complete' ? 'complete' : session.state === 'partial' ? 'partial' : 'unavailable';
5
- }
6
- function sourceMap(result) {
7
- return new Map(result.sourceCoverage.map((item) => [item.source, item.state]));
8
- }
9
- function namedBulkSourceState(result, source) {
10
- return result.sourceCoverage.find((item) => item.source === source)?.state ?? 'not_reported';
11
- }
3
+ function bulkCompleteness(session) { return session.state === 'complete' ? 'complete' : session.state === 'partial' ? 'partial' : 'unavailable'; }
4
+ function sourceMap(result) { return new Map(result.sourceCoverage.map((item) => [item.source, item.state])); }
5
+ function namedBulkSourceState(result, source) { return result.sourceCoverage.find((item) => item.source === source)?.state ?? 'not_reported'; }
12
6
  function bulkFamilyState(result, family) {
13
7
  if (family === 'model')
14
8
  return 'derived';
@@ -47,6 +41,9 @@ function bulkFieldDecision(field, earlier, later, before, after) {
47
41
  if (field.kind === 'registrar')
48
42
  return { state: 'not_compared', completeness: 'not_reported', earlierState, laterState, limitation: 'The exact retained registrar texts are shown, but saved Bulk rows do not retain field-level registrar provenance; these values are not compared as a publication or target change.' };
49
43
  if (field.kind === 'model') {
44
+ if (field.field === 'Risk score' && !areBulkRiskScoresComparable(earlier, later)) {
45
+ return { state: 'not_compared', completeness: 'partial', earlierState, laterState, limitation: 'Risk scores require the same ready Brand Profile provenance, scan depth, and versioned Risk model before comparison.' };
46
+ }
50
47
  const comparable = comparisonLedgerValuePresent(before) && comparisonLedgerValuePresent(after);
51
48
  return { state: comparable ? 'different' : 'not_compared', completeness: comparable ? 'complete' : 'partial', earlierState, laterState, limitation: comparable ? null : 'A score absent from either retained row is not represented as an observed score change.' };
52
49
  }
@@ -69,6 +66,8 @@ function bulkPairCompleteness(earlier, later) {
69
66
  const right = after.get(domain);
70
67
  if (!right || left.scanDepth !== right.scanDepth)
71
68
  return 'partial';
69
+ if (!areBulkRiskScoresComparable(left, right))
70
+ return 'partial';
72
71
  if (left.riskModelVersion === null || right.riskModelVersion === null || left.riskModelVersion !== right.riskModelVersion
73
72
  || left.opportunityModelVersion === null || right.opportunityModelVersion === null || left.opportunityModelVersion !== right.opportunityModelVersion)
74
73
  return 'partial';
@@ -173,7 +173,9 @@ export function comparePageBaselines(rawReference, rawObserved) {
173
173
  complete: observed.complete,
174
174
  truncated: observed.truncated,
175
175
  },
176
- partial: reference.truncated || observed.truncated || components.some((item) => item.partial),
176
+ partial: !reference.complete || !observed.complete
177
+ || reference.truncated || observed.truncated
178
+ || components.some((item) => item.partial),
177
179
  components,
178
180
  counts,
179
181
  };