@slicedearth/whoisleuth-cli 2.0.1 → 2.1.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 (95) hide show
  1. package/cli/arguments.mjs +21 -5
  2. package/cli/artifact-validation/case-response.mjs +771 -0
  3. package/cli/artifact-validation/investigation-capsule.mjs +426 -0
  4. package/cli/artifact-validation/lookup-evidence.mjs +718 -0
  5. package/cli/artifact-validation/signed-review.mjs +598 -0
  6. package/cli/artifact-validation/structure-primitives.mjs +105 -0
  7. package/cli/artifact-verify.mjs +1 -1
  8. package/cli/assurance-command-runner.mjs +324 -0
  9. package/cli/command-reference.mjs +1175 -843
  10. package/cli/completion.mjs +56 -9
  11. package/cli/discriminated-command-handlers.mjs +11 -0
  12. package/cli/doctor.mjs +2 -2
  13. package/cli/evidence-command-runner.mjs +41 -31
  14. package/cli/export-evidence.mjs +1 -1
  15. package/cli/fail-policy.mjs +33 -8
  16. package/cli/formatters/terminal-command-formats.mjs +437 -0
  17. package/cli/formatters/terminal-lookup.mjs +758 -0
  18. package/cli/formatters/terminal-metadata.mjs +58 -0
  19. package/cli/formatters/terminal-shared.mjs +83 -0
  20. package/cli/formatters/terminal.mjs +5 -1232
  21. package/cli/history-command-runner.mjs +132 -0
  22. package/cli/inline-command-families.mjs +45 -0
  23. package/cli/inline-command-runner.mjs +55 -0
  24. package/cli/investigation-plan.mjs +9 -2
  25. package/cli/investigation-run.mjs +114 -9
  26. package/cli/lookup-brief.mjs +141 -23
  27. package/cli/lookup-browser-view.mjs +289 -0
  28. package/cli/lookup-browser.mjs +3 -288
  29. package/cli/mail-review.mjs +2 -6
  30. package/cli/offline-artifact-validation.mjs +19 -0
  31. package/cli/review-command-runner.mjs +269 -0
  32. package/cli/runner.mjs +15 -972
  33. package/cli/saved-lookup.mjs +19 -2
  34. package/cli/support-command-runner.mjs +229 -0
  35. package/cli/workflow-command-runner.mjs +129 -0
  36. package/docs/cli-reference.md +16 -9
  37. package/docs/cli.md +4 -2
  38. package/lib/availability.mjs +12 -9
  39. package/lib/bounded-contract-normalizers.mjs +2 -9
  40. package/lib/browser-library-profile.mjs +1 -2
  41. package/lib/client-behavior-profile.mjs +1 -2
  42. package/lib/credential-surface-profile.mjs +1 -1
  43. package/lib/domain-posture-analysis.mjs +10 -1
  44. package/lib/domain-posture.mjs +51 -23
  45. package/lib/evidence-export.mjs +46 -5
  46. package/lib/evidence-report-markdown.mjs +4 -0
  47. package/lib/evidence-report.mjs +50 -0
  48. package/lib/generated/cisa-kev-catalog.mjs +34 -4
  49. package/lib/generated/registrar-standing-catalogue.mjs +367 -0
  50. package/lib/json-record.mjs +12 -0
  51. package/lib/lookup-child-profile-contract.mjs +858 -0
  52. package/lib/lookup-contract-primitives.mjs +98 -0
  53. package/lib/lookup-diagnostics.mjs +96 -0
  54. package/lib/lookup-network-evidence-bounds.mjs +1 -0
  55. package/lib/lookup-response.mjs +367 -0
  56. package/lib/lookup.mjs +28 -443
  57. package/lib/page-role-profile.mjs +1 -3
  58. package/lib/registrar-standing-catalogue-contract.mjs +70 -0
  59. package/lib/registrar-standing-contract.mjs +300 -0
  60. package/lib/registrar-standing.mjs +269 -0
  61. package/lib/registry-insights.mjs +2 -8
  62. package/lib/risk-scoring.mjs +26 -10
  63. package/lib/sslbl-certificates.generated.mjs +138 -136
  64. package/lib/structured-data-identity.mjs +1 -3
  65. package/lib/technology-evidence-role.mjs +51 -0
  66. package/lib/threat-intelligence-runtime.mjs +3 -3
  67. package/lib/threat-intelligence-types.mjs +13 -0
  68. package/lib/threatfox-intelligence.mjs +1 -3
  69. package/lib/tls-intelligence.mjs +1 -2
  70. package/lib/urlhaus-intelligence.mjs +1 -3
  71. package/lib/urlscan-intelligence.mjs +1 -3
  72. package/lib/website-security-posture.mjs +1 -2
  73. package/lib/website-technology.mjs +72 -23
  74. package/lib/whois-common-formats.mjs +276 -0
  75. package/lib/whois-dialects.mjs +534 -0
  76. package/lib/whois-parser-support.mjs +305 -0
  77. package/lib/whois-parser.mjs +135 -0
  78. package/lib/whois.mjs +6 -1187
  79. package/package.json +2 -2
  80. package/packages/cases/case-evidence-model.mjs +9 -0
  81. package/packages/cases/case-migration-model.mjs +17 -11
  82. package/packages/cases/case-response-model.mjs +13 -9
  83. package/packages/cases/case-response-packet.mjs +297 -112
  84. package/packages/comparison/page-similarity.mjs +23 -0
  85. package/packages/contracts/case-portability.mjs +225 -61
  86. package/packages/contracts/cli-lookup.mjs +4 -3
  87. package/packages/contracts/domain-control-manifest.mjs +1 -1
  88. package/packages/contracts/investigation-portability.mjs +1 -1
  89. package/packages/contracts/risk-calibration.mjs +2 -2
  90. package/packages/contracts/workspace-portability.mjs +34 -6
  91. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  92. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  93. package/packages/workspace/website-snapshot-model.mjs +79 -6
  94. package/third-party-notices.txt +1 -1
  95. package/cli/artifact-structure.mjs +0 -2507
@@ -3,8 +3,8 @@
3
3
  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
4
  import { canonicalArtifactJsonV2, SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
5
5
  import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
6
- import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, 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, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, 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, };
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, };
8
8
  export const CASE_RESPONSE_PREFLIGHT_EVIDENCE_SCOPE = Object.freeze({
9
9
  version: 1,
10
10
  owner: 'case',
@@ -65,7 +65,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
65
65
  evidenceOrder: ['Incident facts', 'Registration evidence', 'Selected observations', 'Action history'],
66
66
  includedEvidence: ['Exact URLs', 'Observation time', 'Case disposition', 'Selected evidence pins', 'Registrar contact provenance'],
67
67
  excludedEvidence: ['Raw WHOIS or RDAP payloads', 'Unselected contacts', 'Provider secrets', 'Unrelated analyst notes'],
68
- redactions: ['Remove unnecessary personal registration data', 'Remove credentials, tokens, and URL fragments'],
68
+ redactions: ['Remove unnecessary personal registration data', 'Credential-bearing URLs are rejected; review query strings and fragments for secrets before sharing'],
69
69
  attachments: ['Reviewed response packet', 'Optional normalised case report'],
70
70
  followUpFields: ['Registrar reference', 'Acknowledgement time', 'Resolution or transfer outcome'],
71
71
  },
@@ -79,7 +79,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
79
79
  evidenceOrder: ['Incident facts', 'Registry publication', 'Prior escalation', 'Selected observations'],
80
80
  includedEvidence: ['Exact URLs', 'Observation time', 'Registry contact provenance', 'Selected evidence pins', 'Prior action references'],
81
81
  excludedEvidence: ['Raw registry payloads', 'Unselected contacts', 'Secrets', 'Unsupported ownership claims'],
82
- redactions: ['Remove unnecessary personal registration data', 'Remove credentials, tokens, and URL fragments'],
82
+ redactions: ['Remove unnecessary personal registration data', 'Credential-bearing URLs are rejected; review query strings and fragments for secrets before sharing'],
83
83
  attachments: ['Reviewed response packet', 'Optional normalised case report', 'Optional registrar reference'],
84
84
  followUpFields: ['Registry reference', 'Registrar referral', 'Delegation or status outcome'],
85
85
  },
@@ -93,7 +93,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
93
93
  evidenceOrder: ['Incident facts', 'Observed endpoint context', 'Selected web evidence', 'Action history'],
94
94
  includedEvidence: ['Exact URLs', 'Observation time', 'Selected HTTP, TLS, DNS, or page-identity pins', 'Contact provenance'],
95
95
  excludedEvidence: ['Raw response bodies', 'Cookies', 'Secrets', 'Unproven origin-host claims'],
96
- redactions: ['Remove credentials, query secrets, fragments, cookies, and unrelated contacts'],
96
+ redactions: ['Credential-bearing URLs are rejected; review query strings, fragments, cookies, and unrelated contacts before sharing'],
97
97
  attachments: ['Reviewed response packet', 'Optional normalised case report'],
98
98
  followUpFields: ['Provider ticket', 'Content status', 'Infrastructure change outcome'],
99
99
  },
@@ -107,7 +107,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
107
107
  evidenceOrder: ['Incident facts', 'Selected observations', 'Analyst reasoning', 'Action history'],
108
108
  includedEvidence: ['Exact URLs', 'Observation time', 'Selected evidence pins', 'Contact source and limitations'],
109
109
  excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected case notes', 'Legal conclusions'],
110
- redactions: ['Remove credentials, tokens, personal data, and unrelated identifiers'],
110
+ redactions: ['Credential-bearing URLs are rejected; review query strings, fragments, tokens, personal data, and unrelated identifiers before sharing'],
111
111
  attachments: ['Reviewed response packet', 'Optional normalised case report'],
112
112
  followUpFields: ['Security reference', 'Triage acknowledgement', 'Remediation outcome'],
113
113
  },
@@ -121,7 +121,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
121
121
  evidenceOrder: ['Exact URLs', 'Observed behaviour', 'Selected corroborating evidence', 'Limitations'],
122
122
  includedEvidence: ['Exact URLs', 'Observation time', 'Observed harm', 'Selected evidence pins', 'Contradictions and cautions'],
123
123
  excludedEvidence: ['Raw provider payloads', 'Secrets', 'Unselected personal data', 'Automated maliciousness claims'],
124
- redactions: ['Remove credentials, tokens, URL fragments, and unrelated contacts'],
124
+ redactions: ['Credential-bearing URLs are rejected; review query strings, fragments, tokens, and unrelated contacts before sharing'],
125
125
  attachments: ['Reviewed response packet', 'Optional normalised case report'],
126
126
  followUpFields: ['Submission reference', 'Review state', 'Listing or delisting outcome'],
127
127
  },
@@ -135,7 +135,7 @@ export const RESPONSE_PACKET_PROFILES = Object.freeze([
135
135
  evidenceOrder: ['Decision packet', 'Selected evidence', 'Incident facts', 'Action and investigation trail'],
136
136
  includedEvidence: ['Case disposition', 'Selected evidence pins', 'Analyst decisions and assertions', 'Action history', 'Exact URLs when relevant'],
137
137
  excludedEvidence: ['Raw upstream payloads', 'Secrets', 'Unselected personal data', 'Unsupported attribution'],
138
- redactions: ['Remove credentials, tokens, and personal data not required for the internal decision'],
138
+ redactions: ['Credential-bearing URLs are rejected; review query strings, fragments, tokens, and unnecessary personal data before sharing'],
139
139
  attachments: ['Reviewed response packet', 'Normalised case report when required by internal policy'],
140
140
  followUpFields: ['Internal owner', 'Due date', 'Decision', 'Control or escalation outcome'],
141
141
  },
@@ -204,43 +204,109 @@ function normalizeLimitations(value) {
204
204
  }
205
205
  return [...unique];
206
206
  }
207
- function normalizeContacts(value, generatedAt) {
208
- if (!Array.isArray(value))
209
- return [];
207
+ function actionContactKind(type) {
208
+ if (type === 'registrar_report')
209
+ return 'registrar';
210
+ if (type === 'registry_report')
211
+ return 'registry';
212
+ if (type === 'network_hosting_report')
213
+ return 'network_hosting';
214
+ if (type === 'security_contact_report')
215
+ return 'security_txt';
216
+ return 'manual';
217
+ }
218
+ function routeFreshness(observedAt, generatedAt) {
219
+ if (!observedAt)
220
+ return 'unknown';
221
+ const routeAge = Date.parse(generatedAt) - Date.parse(observedAt);
222
+ return routeAge < -MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS
223
+ || routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000
224
+ ? 'stale'
225
+ : 'current';
226
+ }
227
+ function selectedPacketAction(caseRecord, input) {
228
+ const actionId = typeof input.actionId === 'string' && /^[A-Za-z0-9_-]{1,64}$/u.test(input.actionId)
229
+ ? input.actionId
230
+ : null;
231
+ return actionId ? caseRecord.actions.find((action) => action.id === actionId) ?? null : null;
232
+ }
233
+ function packetActionLineage(caseRecord, input) {
234
+ const selected = selectedPacketAction(caseRecord, input);
235
+ if (!selected)
236
+ return { selected: null, actions: [], ids: [], complete: true };
237
+ const byId = new Map(caseRecord.actions.map((action) => [action.id, action]));
238
+ const ids = [];
210
239
  const seen = new Set();
211
- const contacts = [];
212
- for (const raw of value.slice(0, MAX_RESPONSE_CONTACTS * 2)) {
213
- if (!raw || typeof raw !== 'object' || Array.isArray(raw))
214
- continue;
215
- const item = raw;
216
- const kind = typeof item.kind === 'string' && CONTACT_KINDS.has(item.kind)
217
- ? item.kind
218
- : null;
219
- const contact = text(item.contact, 320);
220
- if (!kind || !contact)
221
- continue;
222
- const key = `${kind}\u0000${contact.toLowerCase()}`;
223
- if (seen.has(key))
224
- continue;
225
- seen.add(key);
226
- const observedAt = timestamp(item.observedAt);
227
- const routeAge = observedAt && generatedAt ? Date.parse(generatedAt) - Date.parse(observedAt) : null;
228
- contacts.push({
240
+ let current = selected;
241
+ while (current && ids.length < MAX_RESPONSE_ACTION_HISTORY && !seen.has(current.id)) {
242
+ ids.push(current.id);
243
+ seen.add(current.id);
244
+ current = current.originActionId ? byId.get(current.originActionId) : undefined;
245
+ }
246
+ const complete = !current;
247
+ return {
248
+ selected,
249
+ actions: ids.map((id) => byId.get(id)).filter(Boolean),
250
+ ids,
251
+ complete,
252
+ };
253
+ }
254
+ function bindPacketRoute(caseRecord, input, generatedAt) {
255
+ const profile = responsePacketProfile(input.profile);
256
+ const lineage = packetActionLineage(caseRecord, input);
257
+ const action = lineage.selected;
258
+ if (!action) {
259
+ return {
260
+ actionBinding: {
261
+ state: 'not_selected', selectedActionId: null, lineageActionIds: [],
262
+ limitations: ['No retained Case action is selected for this packet.'],
263
+ },
264
+ recipientRoute: null,
265
+ contacts: [],
266
+ };
267
+ }
268
+ const kind = actionContactKind(action.type);
269
+ const compatible = profile.requiredContactKind
270
+ ? kind === profile.requiredContactKind
271
+ : profile.id === 'browser_blocklist'
272
+ ? kind === 'manual'
273
+ : true;
274
+ if (!compatible) {
275
+ return {
276
+ actionBinding: {
277
+ state: 'selected', selectedActionId: action.id, lineageActionIds: [...lineage.ids],
278
+ limitations: [`The selected action does not match the ${profile.label.toLowerCase()} route requirement.`],
279
+ },
280
+ recipientRoute: null,
281
+ contacts: [],
282
+ };
283
+ }
284
+ const observedAt = timestamp(action.routeObservedAt);
285
+ const recipientRoute = {
286
+ actionId: action.id,
287
+ kind,
288
+ contact: text(action.recipient, 320),
289
+ source: text(action.contactSource, 120) || 'analyst supplied',
290
+ observedAt,
291
+ freshness: routeFreshness(observedAt, generatedAt),
292
+ limitations: normalizeLimitations(action.contactLimitations),
293
+ };
294
+ const contacts = kind === 'manual' ? [] : [{
229
295
  kind,
230
- contact,
231
- source: text(item.source, 120) || 'analyst supplied',
296
+ contact: recipientRoute.contact,
297
+ source: recipientRoute.source,
232
298
  observedAt,
233
- freshness: routeAge === null
234
- ? 'unknown'
235
- : routeAge < -300_000 || routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000
236
- ? 'stale'
237
- : 'current',
238
- limitations: normalizeLimitations(item.limitations),
239
- });
240
- if (contacts.length >= MAX_RESPONSE_CONTACTS)
241
- break;
242
- }
243
- return contacts;
299
+ freshness: recipientRoute.freshness,
300
+ limitations: [...recipientRoute.limitations],
301
+ }];
302
+ return {
303
+ actionBinding: {
304
+ state: 'selected', selectedActionId: action.id, lineageActionIds: [...lineage.ids],
305
+ limitations: lineage.complete ? [] : ['The origin-action lineage exceeded the packet action bound or contained a cycle.'],
306
+ },
307
+ recipientRoute,
308
+ contacts,
309
+ };
244
310
  }
245
311
  function normalizeSelectedEvidence(caseRecord, value) {
246
312
  if (!Array.isArray(value))
@@ -292,14 +358,11 @@ function readinessOverride(value, key) {
292
358
  limitations: normalizeLimitations(item.limitations),
293
359
  };
294
360
  }
295
- function buildResponseReadiness(caseRecord, input, generatedAt, contacts, selectedEvidence, contradictions) {
361
+ function buildResponseReadiness(caseRecord, input, generatedAt, contacts, recipientRoute, selectedEvidence, contradictions) {
296
362
  const profile = responsePacketProfile(input.profile);
297
363
  const urls = normalizeUrls(input.abusiveUrls);
298
364
  const observedAt = timestamp(input.observedAt) || caseRecord.evidenceHistory.at(-1)?.capturedAt || null;
299
365
  const age = observedAt ? observationAge(observedAt, generatedAt) : null;
300
- const requiredContact = profile.requiredContactKind
301
- ? contacts.find((contact) => contact.kind === profile.requiredContactKind) ?? null
302
- : contacts[0] ?? null;
303
366
  const infrastructure = readinessOverride(input.readiness, 'infrastructureResponsibility');
304
367
  const authority = readinessOverride(input.readiness, 'authorityReview');
305
368
  const contradictionReview = readinessOverride(input.readiness, 'contradictionsReview');
@@ -342,10 +405,10 @@ function buildResponseReadiness(caseRecord, input, generatedAt, contacts, select
342
405
  },
343
406
  {
344
407
  id: 'recipient_route', label: 'Recipient-route provenance and freshness',
345
- state: !requiredContact ? 'not_provided' : requiredContact.freshness === 'stale' ? 'stale' : requiredContact.freshness === 'current' && requiredContact.source ? 'complete' : 'partial',
346
- detail: !requiredContact ? 'No profile-appropriate recipient route is selected.' : `The selected route provenance is ${requiredContact.source}; freshness is ${requiredContact.freshness}.`,
408
+ state: !recipientRoute ? 'not_provided' : recipientRoute.freshness === 'stale' ? 'stale' : recipientRoute.freshness === 'current' && recipientRoute.source ? 'complete' : 'partial',
409
+ detail: !recipientRoute ? 'No profile-appropriate recipient route is selected.' : `The selected route provenance is ${recipientRoute.source}; freshness is ${recipientRoute.freshness}.`,
347
410
  requiredForAuthorisation: external,
348
- limitations: requiredContact?.limitations ?? [],
411
+ limitations: recipientRoute?.limitations ?? [],
349
412
  },
350
413
  {
351
414
  id: 'authority_review', label: 'Authority review',
@@ -397,9 +460,9 @@ function buildResponseReadiness(caseRecord, input, generatedAt, contacts, select
397
460
  }
398
461
  export function buildCaseResponseReadiness(caseRecord, input, generatedAt = new Date().toISOString()) {
399
462
  const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
400
- const contacts = normalizeContacts(input.contacts, normalizedGeneratedAt);
463
+ const binding = bindPacketRoute(caseRecord, input, normalizedGeneratedAt);
401
464
  const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
402
- return buildResponseReadiness(caseRecord, input, normalizedGeneratedAt, contacts, selectedEvidence, normalizeContradictions(caseRecord));
465
+ return buildResponseReadiness(caseRecord, input, normalizedGeneratedAt, binding.contacts, binding.recipientRoute, selectedEvidence, normalizeContradictions(caseRecord));
403
466
  }
404
467
  function normalizeArtefactReferences(value) {
405
468
  if (!Array.isArray(value))
@@ -443,13 +506,16 @@ export function responsePacketProfile(value) {
443
506
  }
444
507
  export function buildResponsePacketProfilePreview(caseRecord, input) {
445
508
  const profile = responsePacketProfile(input.profile);
446
- const contacts = normalizeContacts(input.contacts);
509
+ const binding = bindPacketRoute(caseRecord, input, caseRecord.updatedAt);
447
510
  const missingEvidence = [
448
511
  ...(!normalizeUrls(input.abusiveUrls).length ? ['At least one exact HTTP(S) URL'] : []),
449
512
  ...(!timestamp(input.observedAt) && !caseRecord.evidenceHistory.at(-1)?.capturedAt ? ['Observation time'] : []),
450
513
  ...(!normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds).length ? ['Explicitly selected evidence pin'] : []),
451
- ...(profile.requiredContactKind && !contacts.some((contact) => contact.kind === profile.requiredContactKind)
452
- ? [`${contactLabel(profile.requiredContactKind)} contact route`]
514
+ ...(profile.id !== 'internal_soc' && binding.actionBinding.state !== 'selected'
515
+ ? ['Selected retained Case action']
516
+ : []),
517
+ ...(profile.id !== 'internal_soc' && !binding.recipientRoute
518
+ ? [`${profile.requiredContactKind ? contactLabel(profile.requiredContactKind) : 'Manual submission'} contact route`]
453
519
  : []),
454
520
  ];
455
521
  const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH) || 'domain activity';
@@ -472,7 +538,7 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
472
538
  const latestEvidence = caseRecord.evidenceHistory.at(-1) ?? null;
473
539
  const observedAt = timestamp(input.observedAt) || latestEvidence?.capturedAt || null;
474
540
  const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
475
- const contacts = normalizeContacts(input.contacts, normalizedGeneratedAt);
541
+ const binding = bindPacketRoute(caseRecord, input, normalizedGeneratedAt);
476
542
  const urls = normalizeUrls(input.abusiveUrls);
477
543
  const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
478
544
  const requiredComplete = Boolean(text(input.category, MAX_ABUSE_CATEGORY_LENGTH)
@@ -485,6 +551,10 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
485
551
  .filter((item) => item.kind === 'contradiction' && item.state === 'open')
486
552
  .length;
487
553
  const actionSummary = buildCaseActionOutcomeSummary(caseRecord.actions, normalizedGeneratedAt);
554
+ const retainedPinIds = new Set(caseRecord.evidencePins.map((pin) => pin.id));
555
+ const evidenceLinkedDecisionCount = caseRecord.decisions.filter((decision) => decision.evidencePinIds.some((evidencePinId) => retainedPinIds.has(evidencePinId))).length;
556
+ const responseDisposition = ['suspicious', 'confirmed_abuse'].includes(caseRecord.disposition);
557
+ const reviewedActionCount = caseRecord.actions.filter((action) => ['reviewed', 'authorised', 'submitted', 'acknowledged', 'terminal'].includes(action.state)).length;
488
558
  const profile = responsePacketProfile(input.profile);
489
559
  const checks = [
490
560
  {
@@ -506,38 +576,48 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
506
576
  {
507
577
  id: 'analyst_decision',
508
578
  label: 'Analyst decision',
509
- state: caseRecord.decisions.length ? 'pass' : 'caution',
510
- detail: caseRecord.decisions.length
511
- ? `${caseRecord.decisions.length} analyst decision${caseRecord.decisions.length === 1 ? '' : 's'} record the escalation rationale.`
512
- : 'No explicit analyst decision explains why external reporting is appropriate.',
579
+ state: evidenceLinkedDecisionCount ? 'pass' : 'caution',
580
+ detail: evidenceLinkedDecisionCount
581
+ ? `${evidenceLinkedDecisionCount} analyst decision${evidenceLinkedDecisionCount === 1 ? '' : 's'} cite retained evidence and record the escalation rationale.`
582
+ : caseRecord.decisions.length
583
+ ? 'Analyst decisions are recorded, but none cites a retained evidence pin.'
584
+ : 'No explicit analyst decision explains why external reporting is appropriate.',
513
585
  },
514
586
  {
515
587
  id: 'recipient_route',
516
588
  label: 'Recipient route',
517
- state: contacts.length ? 'pass' : 'caution',
518
- detail: contacts.length
519
- ? `${contacts.length} separately attributed contact route${contacts.length === 1 ? ' is' : 's are'} included.`
589
+ state: binding.recipientRoute ? 'pass' : profile.id === 'internal_soc' ? 'caution' : 'block',
590
+ detail: binding.recipientRoute
591
+ ? `The selected ${contactLabel(binding.recipientRoute.kind)} route is bound to action ${binding.recipientRoute.actionId}.`
520
592
  : 'No contact route is included; identify and review the intended recipient before sending.',
521
593
  },
522
594
  {
523
595
  id: 'profile_recipient',
524
596
  label: 'Audience-specific recipient',
525
- state: profile.requiredContactKind
526
- ? contacts.some((contact) => contact.kind === profile.requiredContactKind) ? 'pass' : 'caution'
527
- : 'pass',
597
+ state: profile.id === 'internal_soc'
598
+ ? 'pass'
599
+ : binding.recipientRoute ? 'pass' : 'block',
528
600
  detail: profile.requiredContactKind
529
- ? contacts.some((contact) => contact.kind === profile.requiredContactKind)
601
+ ? binding.recipientRoute?.kind === profile.requiredContactKind
530
602
  ? `The ${contactLabel(profile.requiredContactKind)} route required by the ${profile.label.toLowerCase()} is present.`
531
603
  : `The ${profile.label.toLowerCase()} expects a separately attributed ${contactLabel(profile.requiredContactKind)} route.`
532
- : `${profile.label} has no fixed external contact-kind requirement; confirm the intended manual destination.`,
604
+ : profile.id === 'browser_blocklist'
605
+ ? binding.recipientRoute
606
+ ? 'A manually reviewed submission destination is bound to the selected Case action.'
607
+ : 'The browser or blocklist profile requires a manually reviewed submission destination recorded as a Case action.'
608
+ : `${profile.label} has no fixed external contact-kind requirement.`,
533
609
  },
534
610
  {
535
611
  id: 'case_disposition',
536
612
  label: 'Case disposition',
537
- state: ['suspicious', 'confirmed_abuse'].includes(caseRecord.disposition) ? 'pass' : 'caution',
538
- detail: ['suspicious', 'confirmed_abuse'].includes(caseRecord.disposition)
539
- ? `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')}.`
540
- : `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')}; confirm it before external use.`,
613
+ state: responseDisposition && caseRecord.reviewReasonCode && evidenceLinkedDecisionCount ? 'pass' : 'caution',
614
+ detail: responseDisposition && caseRecord.reviewReasonCode && evidenceLinkedDecisionCount
615
+ ? `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')} with the reviewed reason ${caseRecord.reviewReasonCode.replaceAll('_', ' ')} and an evidence-linked decision.`
616
+ : !responseDisposition
617
+ ? `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')}; confirm it before external use.`
618
+ : !caseRecord.reviewReasonCode
619
+ ? `The case disposition is ${caseRecord.disposition.replaceAll('_', ' ')}, but no reviewed reason is recorded.`
620
+ : 'The case disposition and reason are recorded, but no analyst decision cites retained evidence.',
541
621
  },
542
622
  {
543
623
  id: 'evidence_freshness',
@@ -557,13 +637,23 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
557
637
  ? `${openContradictions} open contradiction${openContradictions === 1 ? '' : 's'} should be addressed or disclosed.`
558
638
  : 'No open contradictory-evidence assertion is recorded.',
559
639
  },
640
+ {
641
+ id: 'packet_action',
642
+ label: 'Packet action',
643
+ state: binding.actionBinding.state === 'selected' ? 'pass' : profile.id === 'internal_soc' ? 'caution' : 'block',
644
+ detail: binding.actionBinding.state === 'selected'
645
+ ? `Action ${binding.actionBinding.selectedActionId} owns this packet and its retained origin lineage.`
646
+ : 'Select the retained Case action this packet prepares or documents.',
647
+ },
560
648
  {
561
649
  id: 'action_tracking',
562
650
  label: 'Action tracking',
563
- state: actionSummary.total ? 'pass' : 'caution',
564
- detail: actionSummary.total
565
- ? `${actionSummary.total} reviewed action${actionSummary.total === 1 ? ' is' : 's are'} tracked; ${actionSummary.overdue} overdue and ${actionSummary.followUpDue} due for follow-up.`
566
- : 'No reviewed case action is recorded for ownership, submission, or follow-up.',
651
+ state: reviewedActionCount ? 'pass' : 'caution',
652
+ detail: reviewedActionCount
653
+ ? `${reviewedActionCount} reviewed or later-stage action${reviewedActionCount === 1 ? ' is' : 's are'} tracked; ${actionSummary.overdue} overdue and ${actionSummary.followUpDue} due for follow-up.`
654
+ : actionSummary.total
655
+ ? `${actionSummary.total} action${actionSummary.total === 1 ? ' remains' : 's remain'} before reviewed state.`
656
+ : 'No reviewed case action is recorded for ownership, submission, or follow-up.',
567
657
  },
568
658
  ];
569
659
  const counts = {
@@ -572,7 +662,7 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
572
662
  pass: checks.filter((item) => item.state === 'pass').length,
573
663
  };
574
664
  return {
575
- version: 2,
665
+ version: 3,
576
666
  status: counts.block ? 'needs_input' : counts.caution ? 'review_cautions' : 'ready_for_review',
577
667
  canExport: counts.block === 0,
578
668
  counts,
@@ -580,16 +670,17 @@ export function buildCaseResponsePreflight(caseRecord, input, generatedAt = new
580
670
  actionSummary,
581
671
  };
582
672
  }
583
- function normalizeActionHistory(caseRecord) {
584
- const omitted = Math.max(0, caseRecord.actions.length - MAX_RESPONSE_ACTION_HISTORY);
673
+ function normalizeActionHistory(caseRecord, input) {
674
+ const lineage = packetActionLineage(caseRecord, input);
675
+ const omitted = Math.max(0, caseRecord.actions.length - lineage.actions.length);
585
676
  return {
586
- actions: caseRecord.actions
587
- .slice(-MAX_RESPONSE_ACTION_HISTORY)
677
+ actions: lineage.actions
588
678
  .map((action) => ({
589
679
  actionId: action.id,
590
680
  type: text(action.type, 80),
591
681
  recipient: text(action.recipient, 320),
592
682
  contactSource: text(action.contactSource, 120),
683
+ routeObservedAt: timestamp(action.routeObservedAt),
593
684
  state: text(action.state, 80),
594
685
  reference: text(action.reference, 500) || null,
595
686
  providerOutcome: text(action.providerOutcome, 80) || null,
@@ -616,18 +707,37 @@ function normalizeActionHistory(caseRecord) {
616
707
  updatedAt: timestamp(action.updatedAt) ?? caseRecord.updatedAt,
617
708
  })),
618
709
  omitted,
619
- limitations: omitted ? [
620
- `${omitted} earlier Case response action${omitted === 1 ? '' : 's'} omitted from this bounded packet projection.`,
621
- 'Provider-outcome time is withheld because an omitted action could contain a later or concurrent typed event.',
622
- ] : [],
710
+ limitations: [
711
+ ...(omitted ? [`${omitted} Case response action${omitted === 1 ? '' : 's'} outside the retained selected-action lineage ${omitted === 1 ? 'was' : 'were'} excluded from this packet.`] : []),
712
+ ...(!lineage.complete ? ['The selected action origin lineage exceeded the packet bound or contained a cycle; provider-outcome time is withheld.'] : []),
713
+ ],
714
+ actionBinding: lineage.selected ? {
715
+ state: 'selected',
716
+ selectedActionId: lineage.selected.id,
717
+ lineageActionIds: [...lineage.ids],
718
+ limitations: lineage.complete ? [] : ['The origin-action lineage is incomplete.'],
719
+ } : {
720
+ state: 'not_selected', selectedActionId: null, lineageActionIds: [],
721
+ limitations: ['No retained Case action is selected for this packet.'],
722
+ },
723
+ lineageComplete: lineage.complete,
623
724
  };
624
725
  }
625
- function normalizeResponseLifecycle(caseRecord, escalationHistoryOmitted) {
626
- const summary = buildCaseResponseLifecycleSummary(caseRecord);
627
- const providerOutcomeState = escalationHistoryOmitted ? 'ambiguous' : summary.providerOutcomeState;
726
+ function normalizeResponseLifecycle(caseRecord, actionIds, lineageComplete) {
727
+ const scopedIds = new Set(actionIds);
728
+ const scopedRecord = {
729
+ ...caseRecord,
730
+ actions: caseRecord.actions.filter((action) => scopedIds.has(action.id)),
731
+ closures: {
732
+ ...caseRecord.closures,
733
+ records: caseRecord.closures.records.filter((closure) => closure.actionId !== null && scopedIds.has(closure.actionId)),
734
+ },
735
+ };
736
+ const summary = buildCaseResponseLifecycleSummary(scopedRecord);
737
+ const providerOutcomeState = lineageComplete ? summary.providerOutcomeState : 'ambiguous';
628
738
  return {
629
739
  providerOutcomeState,
630
- latestProviderOutcome: !escalationHistoryOmitted && summary.latestProviderOutcome ? { ...summary.latestProviderOutcome } : null,
740
+ latestProviderOutcome: lineageComplete && summary.latestProviderOutcome ? { ...summary.latestProviderOutcome } : null,
631
741
  observedChangeState: summary.observedChangeState,
632
742
  latestObservedEffect: summary.latestObservedEffect ? { ...summary.latestObservedEffect } : null,
633
743
  latestObservedChangeAt: summary.latestObservedChangeAt,
@@ -641,7 +751,7 @@ function normalizeResponseLifecycle(caseRecord, escalationHistoryOmitted) {
641
751
  'Provider workflow outcomes and independently observed technical effects are separate point-in-time records.',
642
752
  'A provider acknowledgement, terminal state, or reported resolution never becomes independently observed remediation, absence, or safety.',
643
753
  'Times are withheld when the corresponding typed event is missing or ambiguous.',
644
- ...(escalationHistoryOmitted ? ['Provider-outcome time is withheld because the bounded packet omits earlier response actions.'] : []),
754
+ ...(!lineageComplete ? ['Provider-outcome time is withheld because the selected action origin lineage is incomplete.'] : []),
645
755
  ],
646
756
  };
647
757
  }
@@ -666,11 +776,11 @@ async function sha256(value) {
666
776
  export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
667
777
  const profile = buildResponsePacketProfilePreview(caseRecord, input);
668
778
  const category = text(input.category, MAX_ABUSE_CATEGORY_LENGTH);
669
- const contacts = normalizeContacts(input.contacts, generatedAt);
670
779
  const selectedEvidence = normalizeSelectedEvidence(caseRecord, input.selectedEvidencePinIds);
671
780
  const contradictions = normalizeContradictions(caseRecord);
672
- const readiness = buildResponseReadiness(caseRecord, input, generatedAt, contacts, selectedEvidence, contradictions);
673
- const escalation = normalizeActionHistory(caseRecord);
781
+ const binding = bindPacketRoute(caseRecord, input, generatedAt);
782
+ const readiness = buildResponseReadiness(caseRecord, input, generatedAt, binding.contacts, binding.recipientRoute, selectedEvidence, contradictions);
783
+ const escalation = normalizeActionHistory(caseRecord, input);
674
784
  return {
675
785
  contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
676
786
  version: CASE_RESPONSE_REVIEW_INPUTS_VERSION,
@@ -698,7 +808,9 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
698
808
  observedHarm: text(input.observedHarm, MAX_RESPONSE_HARM_LENGTH),
699
809
  observedAt: timestamp(input.observedAt) || caseRecord.evidenceHistory.at(-1)?.capturedAt || null,
700
810
  },
701
- contacts,
811
+ contacts: binding.contacts,
812
+ recipientRoute: binding.recipientRoute,
813
+ actionBinding: escalation.actionBinding,
702
814
  selectedEvidence,
703
815
  contradictions,
704
816
  readiness,
@@ -706,7 +818,7 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
706
818
  escalationHistory: escalation.actions,
707
819
  escalationHistoryOmitted: escalation.omitted,
708
820
  escalationHistoryLimitations: escalation.limitations,
709
- responseLifecycle: normalizeResponseLifecycle(caseRecord, escalation.omitted),
821
+ responseLifecycle: normalizeResponseLifecycle(caseRecord, escalation.actionBinding.lineageActionIds, escalation.lineageComplete),
710
822
  };
711
823
  }
712
824
  const CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze([
@@ -716,6 +828,8 @@ const CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze([
716
828
  'case',
717
829
  'incident',
718
830
  'contacts',
831
+ 'recipientRoute',
832
+ 'actionBinding',
719
833
  'selectedEvidence',
720
834
  'contradictions',
721
835
  'readiness',
@@ -725,6 +839,7 @@ const CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze([
725
839
  'escalationHistoryLimitations',
726
840
  'responseLifecycle',
727
841
  ]);
842
+ const PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze(CASE_RESPONSE_REVIEW_INPUT_KEYS.filter((key) => key !== 'recipientRoute' && key !== 'actionBinding'));
728
843
  function exactReviewRecord(value, keys, label) {
729
844
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
730
845
  throw new TypeError(`${label} must be an exact object.`);
@@ -832,11 +947,20 @@ function recursivelyFreezeReviewValue(value) {
832
947
  return Object.freeze(value);
833
948
  }
834
949
  export function validateCaseResponseReviewInputs(value) {
835
- const source = exactReviewRecord(value, CASE_RESPONSE_REVIEW_INPUT_KEYS, 'Case-response review inputs');
836
- if (source.contract !== CASE_RESPONSE_REVIEW_INPUTS_SCHEMA
837
- || source.version !== CASE_RESPONSE_REVIEW_INPUTS_VERSION) {
950
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
951
+ throw new TypeError('Case-response review inputs must be an exact object.');
952
+ }
953
+ const descriptors = Object.getOwnPropertyDescriptors(value);
954
+ if (!descriptors.contract || !Object.hasOwn(descriptors.contract, 'value')
955
+ || !descriptors.version || !Object.hasOwn(descriptors.version, 'value')) {
956
+ throw new TypeError('Case-response review inputs must not contain accessors.');
957
+ }
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)) {
838
961
  throw new TypeError('Case-response review inputs contain an unsupported version, shape, or bound.');
839
962
  }
963
+ const source = exactReviewRecord(value, current ? CASE_RESPONSE_REVIEW_INPUT_KEYS : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS, 'Case-response review inputs');
840
964
  const profile = exactReviewRecord(source.profile, [
841
965
  'id', 'label', 'audience', 'subject', 'checklist', 'includedEvidence',
842
966
  'excludedEvidence', 'redactions',
@@ -880,6 +1004,38 @@ export function validateCaseResponseReviewInputs(value) {
880
1004
  reviewText(contact.observedAt, 64, 'Case-response contact observation time', true);
881
1005
  reviewStrings(contact.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response contact limitations');
882
1006
  }
1007
+ if (current && source.recipientRoute !== null) {
1008
+ const route = exactReviewRecord(source.recipientRoute, [
1009
+ 'actionId', 'kind', 'contact', 'source', 'observedAt', 'freshness', 'limitations',
1010
+ ], 'Case-response recipient route');
1011
+ reviewText(route.actionId, 64, 'Case-response recipient action id');
1012
+ reviewEnum(route.kind, [...RESPONSE_CONTACT_KINDS, 'manual'], 'Case-response recipient kind');
1013
+ reviewText(route.contact, 320, 'Case-response recipient value');
1014
+ reviewText(route.source, 120, 'Case-response recipient source');
1015
+ reviewText(route.observedAt, 64, 'Case-response recipient observation time', true);
1016
+ reviewEnum(route.freshness, ['current', 'stale', 'unknown'], 'Case-response recipient freshness');
1017
+ reviewStrings(route.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response recipient limitations');
1018
+ }
1019
+ let currentLineageActionIds = null;
1020
+ if (current) {
1021
+ const actionBinding = exactReviewRecord(source.actionBinding, [
1022
+ 'state', 'selectedActionId', 'lineageActionIds', 'limitations',
1023
+ ], 'Case-response action binding');
1024
+ const bindingState = reviewEnum(actionBinding.state, ['selected', 'not_selected'], 'Case-response action binding state');
1025
+ const selectedActionId = reviewText(actionBinding.selectedActionId, 64, 'Case-response selected action id', true);
1026
+ const lineageActionIds = reviewStrings(actionBinding.lineageActionIds, MAX_RESPONSE_ACTION_HISTORY, 64, 'Case-response action lineage');
1027
+ currentLineageActionIds = lineageActionIds;
1028
+ if ((bindingState === 'selected') !== Boolean(selectedActionId)
1029
+ || (selectedActionId !== null && lineageActionIds[0] !== selectedActionId)
1030
+ || new Set(lineageActionIds).size !== lineageActionIds.length) {
1031
+ throw new TypeError('Case-response action binding is inconsistent.');
1032
+ }
1033
+ reviewStrings(actionBinding.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response action-binding limitations');
1034
+ if (source.recipientRoute !== null
1035
+ && source.recipientRoute.actionId !== selectedActionId) {
1036
+ throw new TypeError('Case-response recipient route is not bound to the selected action.');
1037
+ }
1038
+ }
883
1039
  for (const candidate of boundedReviewArray(source.selectedEvidence, MAX_RESPONSE_SELECTED_EVIDENCE, 'Case-response selected evidence')) {
884
1040
  const evidence = exactReviewRecord(candidate, [
885
1041
  'id', 'label', 'source', 'observedAt', 'completeness', 'limitations',
@@ -951,15 +1107,23 @@ export function validateCaseResponseReviewInputs(value) {
951
1107
  reviewCount(reference.byteLength, 100 * 1024 * 1024, 'Case-response artefact byte length');
952
1108
  reviewStrings(reference.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response artefact limitations');
953
1109
  }
1110
+ const escalationActionIds = [];
954
1111
  for (const candidate of boundedReviewArray(source.escalationHistory, MAX_RESPONSE_ACTION_HISTORY, 'Case-response escalation history')) {
955
1112
  const action = exactReviewRecord(candidate, [
956
1113
  'actionId', 'type', 'recipient', 'contactSource', 'state', 'reference',
1114
+ ...(current ? ['routeObservedAt'] : []),
957
1115
  'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
958
1116
  'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
959
1117
  ], 'Case-response escalation action');
960
1118
  for (const key of ['actionId', 'recipient', 'contactSource', 'createdAt', 'updatedAt']) {
961
1119
  reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`);
962
1120
  }
1121
+ if (escalationActionIds.includes(action.actionId)) {
1122
+ throw new TypeError('Case-response escalation action identities must be unique.');
1123
+ }
1124
+ escalationActionIds.push(action.actionId);
1125
+ if (current)
1126
+ reviewText(action.routeObservedAt, 64, 'Case-response action route observation time', true);
963
1127
  reviewEnum(action.type, CASE_ACTION_TYPES, 'Case-response action type');
964
1128
  reviewEnum(action.state, CASE_ACTION_STATES, 'Case-response action state');
965
1129
  for (const key of ['reference', 'outcomeDetail', 'originActionId']) {
@@ -989,6 +1153,11 @@ export function validateCaseResponseReviewInputs(value) {
989
1153
  reviewStrings(transition.limitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response transition limitations');
990
1154
  }
991
1155
  }
1156
+ if (currentLineageActionIds
1157
+ && (currentLineageActionIds.length !== escalationActionIds.length
1158
+ || escalationActionIds.some((id, index) => currentLineageActionIds?.[index] !== id))) {
1159
+ throw new TypeError('Case-response escalation history must match the selected action lineage in order.');
1160
+ }
992
1161
  reviewCount(source.escalationHistoryOmitted, MAX_CASE_ACTIONS, 'Case-response omitted action count');
993
1162
  reviewStrings(source.escalationHistoryLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response escalation limitations');
994
1163
  validateReviewLifecycle(source.responseLifecycle);
@@ -1068,9 +1237,11 @@ function escapeMarkdown(value) {
1068
1237
  }
1069
1238
  function contactLabel(value) {
1070
1239
  if (value === 'network_hosting')
1071
- return 'Network or hosting';
1240
+ return 'Observed endpoint network registration';
1072
1241
  if (value === 'security_txt')
1073
1242
  return 'security.txt';
1243
+ if (value === 'manual')
1244
+ return 'Manual submission';
1074
1245
  return value.charAt(0).toUpperCase() + value.slice(1);
1075
1246
  }
1076
1247
  export async function buildCaseResponsePacket(caseRecord, input, generatedAt = new Date().toISOString()) {
@@ -1086,6 +1257,8 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
1086
1257
  const normalizedGeneratedAt = timestamp(generatedAt) || new Date().toISOString();
1087
1258
  const reviewMaterial = buildCaseResponseReviewInputs(caseRecord, input, normalizedGeneratedAt);
1088
1259
  const contacts = reviewMaterial.contacts;
1260
+ const recipientRoute = reviewMaterial.recipientRoute;
1261
+ const actionBinding = reviewMaterial.actionBinding;
1089
1262
  const selectedEvidence = reviewMaterial.selectedEvidence;
1090
1263
  const contradictions = reviewMaterial.contradictions;
1091
1264
  const readiness = reviewMaterial.readiness;
@@ -1141,6 +1314,8 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
1141
1314
  observedAt,
1142
1315
  },
1143
1316
  contacts,
1317
+ recipientRoute,
1318
+ actionBinding,
1144
1319
  selectedEvidence,
1145
1320
  contradictions,
1146
1321
  readiness,
@@ -1189,24 +1364,26 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
1189
1364
  '',
1190
1365
  ...abusiveUrls.map((url) => `- ${escapeMarkdown(url)}`),
1191
1366
  '',
1192
- '## Escalation contacts',
1367
+ '## Selected response route',
1193
1368
  '',
1194
- ...(contacts.length
1195
- ? contacts.flatMap((contact) => [
1196
- `### ${contactLabel(contact.kind)}`,
1369
+ ...(recipientRoute
1370
+ ? [
1371
+ `### ${contactLabel(recipientRoute.kind)}`,
1197
1372
  '',
1198
- `- Contact: ${escapeMarkdown(contact.contact)}`,
1199
- `- Source: ${escapeMarkdown(contact.source)}`,
1200
- `- Route observed: ${contact.observedAt ?? 'Not provided'} (${contact.freshness})`,
1201
- `- Limitations: ${contact.limitations.length ? contact.limitations.map(escapeMarkdown).join('; ') : 'None recorded'}`,
1373
+ `- Case action: ${escapeMarkdown(recipientRoute.actionId)}`,
1374
+ `- Contact: ${escapeMarkdown(recipientRoute.contact)}`,
1375
+ `- Source: ${escapeMarkdown(recipientRoute.source)}`,
1376
+ `- Route observed: ${recipientRoute.observedAt ?? 'Not provided'} (${recipientRoute.freshness})`,
1377
+ `- Limitations: ${recipientRoute.limitations.length ? recipientRoute.limitations.map(escapeMarkdown).join('; ') : 'None recorded'}`,
1202
1378
  '',
1203
- ])
1204
- : ['No escalation contact was included.', '']),
1205
- '## Escalation history',
1379
+ ]
1380
+ : ['No profile-appropriate response route was bound to this packet.', '']),
1381
+ '## Selected action lineage',
1206
1382
  '',
1207
1383
  ...(escalationHistory.length
1208
1384
  ? escalationHistory.flatMap((action) => [
1209
1385
  `- ${escapeMarkdown(action.type.replaceAll('_', ' '))} to ${escapeMarkdown(action.recipient)} · ${escapeMarkdown(action.state.replaceAll('_', ' '))} · updated ${action.updatedAt}`,
1386
+ ` - Route observed: ${action.routeObservedAt ?? 'Not provided'}`,
1210
1387
  ...(action.reference ? [` - Reference: ${escapeMarkdown(action.reference)}`] : []),
1211
1388
  ...(action.providerOutcome ? [` - Typed provider outcome: ${escapeMarkdown(action.providerOutcome.replaceAll('_', ' '))}`] : []),
1212
1389
  ...(action.outcomeDetail ? [` - Outcome detail: ${escapeMarkdown(action.outcomeDetail)}`] : []),
@@ -1221,8 +1398,8 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
1221
1398
  ...(action.historyOmitted ? [` - ${action.historyOmitted} earlier transition event${action.historyOmitted === 1 ? '' : 's'} omitted by bound.`] : []),
1222
1399
  ...action.historyLimitations.map((limitation) => ` - History limitation: ${escapeMarkdown(limitation)}`),
1223
1400
  ])
1224
- : ['No reviewed case actions were recorded.']),
1225
- ...(escalationHistoryOmitted ? [`- Earlier actions omitted from packet projection: ${escalationHistoryOmitted}`] : []),
1401
+ : ['No Case action was selected.']),
1402
+ ...(escalationHistoryOmitted ? [`- Unrelated Case actions excluded from packet: ${escalationHistoryOmitted}`] : []),
1226
1403
  ...escalationHistoryLimitations.map((limitation) => `- Packet history limitation: ${escapeMarkdown(limitation)}`),
1227
1404
  '',
1228
1405
  '## Readiness and authorisation',
@@ -1270,7 +1447,7 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
1270
1447
  ...profile.checklist.map((item) => `- Checklist: ${escapeMarkdown(item)}`),
1271
1448
  ...profile.includedEvidence.map((item) => `- Included: ${escapeMarkdown(item)}`),
1272
1449
  ...profile.excludedEvidence.map((item) => `- Excluded: ${escapeMarkdown(item)}`),
1273
- ...profile.redactions.map((item) => `- Redaction: ${escapeMarkdown(item)}`),
1450
+ ...profile.redactions.map((item) => `- Redaction to confirm: ${escapeMarkdown(item)}`),
1274
1451
  ...profile.attachments.map((item) => `- Attachment expectation: ${escapeMarkdown(item)}`),
1275
1452
  ...profile.followUpFields.map((item) => `- Follow-up field: ${escapeMarkdown(item)}`),
1276
1453
  ];
@@ -1290,6 +1467,14 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
1290
1467
  'Exact URLs:',
1291
1468
  ...abusiveUrls.map((url) => `- ${url}`),
1292
1469
  '',
1470
+ 'Selected evidence:',
1471
+ ...(selectedEvidence.length
1472
+ ? selectedEvidence.map((item) => `- ${item.label} — ${item.source}, observed ${item.observedAt} (${item.completeness})`)
1473
+ : ['- No Case evidence pin was selected.']),
1474
+ '',
1475
+ `Reviewed packet SHA-256: ${digestSha256}`,
1476
+ 'Attach the reviewed packet (and any separately reviewed evidence files) through the recipient’s approved submission channel; this message does not embed or transmit attachments.',
1477
+ '',
1293
1478
  responseLifecycle.latestProviderOutcome
1294
1479
  ? `Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' ')})`
1295
1480
  : `Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,