@slicedearth/whoisleuth-cli 2.5.1 → 2.7.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 (170) hide show
  1. package/DISCLOSURE +9 -6
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -814
  5. package/cli/artifact-validation/lookup-evidence.mjs +30 -3
  6. package/cli/artifact-validation/signed-review.mjs +2 -51
  7. package/cli/artifact-verify.mjs +41 -3
  8. package/cli/bulk-command-runner.mjs +4 -6
  9. package/cli/case-command.mjs +92 -16
  10. package/cli/case-pack.mjs +14 -3
  11. package/cli/ci-report.mjs +17 -5
  12. package/cli/command-definition.mjs +7 -6
  13. package/cli/discovery-scan-command-runner.mjs +4 -6
  14. package/cli/doctor.mjs +37 -3
  15. package/cli/domain-feed.mjs +61 -0
  16. package/cli/formatters/html.mjs +3 -0
  17. package/cli/formatters/json.mjs +9 -14
  18. package/cli/formatters/terminal-lookup.mjs +24 -1
  19. package/cli/history-command-definitions.mjs +1 -1
  20. package/cli/indicator-set-command.mjs +1 -2
  21. package/cli/intake-command.mjs +43 -3
  22. package/cli/investigation-artifacts.mjs +2 -3
  23. package/cli/investigation-package-review.mjs +2 -0
  24. package/cli/investigation-plan.mjs +15 -0
  25. package/cli/local-mmdb-review.mjs +27 -4
  26. package/cli/lookup-brief.mjs +11 -5
  27. package/cli/lookup-command-runner.mjs +21 -13
  28. package/cli/offline-evidence-review.mjs +51 -5
  29. package/cli/review-arguments.mjs +3 -2
  30. package/cli/review-command-definitions.mjs +23 -17
  31. package/cli/review-command-runner.mjs +6 -3
  32. package/cli/risk-calibration.mjs +6 -4
  33. package/cli/saved-lookup.mjs +22 -7
  34. package/cli/selected-input-worker.mjs +12 -2
  35. package/cli/source-reliability.mjs +12 -10
  36. package/cli/support-arguments.mjs +1 -1
  37. package/cli/support-command-definitions.mjs +5 -4
  38. package/cli/support-command-runner.mjs +9 -7
  39. package/cli/watchlist-review.mjs +43 -0
  40. package/cli/workflow-arguments.mjs +14 -0
  41. package/cli/workflow-command-definitions.mjs +27 -1
  42. package/cli/workflow-command-runner.mjs +32 -0
  43. package/docs/cli-reference.md +49 -11
  44. package/docs/cli.md +200 -26
  45. package/docs/contextual-reviews.md +38 -5
  46. package/docs/infrastructure-observations.md +135 -0
  47. package/docs/offline-intake-context.md +213 -0
  48. package/lib/credential-form-attribution.mjs +136 -0
  49. package/lib/credential-surface-profile.mjs +6 -2
  50. package/lib/dmarc-discovery.mjs +4 -2
  51. package/lib/dns-delegation-health.mjs +23 -7
  52. package/lib/dns-inheritance-review.mjs +14 -1
  53. package/lib/dns-intelligence.mjs +18 -4
  54. package/lib/domain-posture-analysis.mjs +53 -2
  55. package/lib/domain-posture-parsers.mjs +9 -0
  56. package/lib/domain-posture.mjs +29 -10
  57. package/lib/evidence-export-privacy.mjs +5 -2
  58. package/lib/evidence-export.mjs +10 -4
  59. package/lib/evidence-report.mjs +3 -1
  60. package/lib/favicon.mjs +13 -4
  61. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  62. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  63. package/lib/generated/retire-browser-catalog.mjs +178 -5
  64. package/lib/html-signals.mjs +6 -0
  65. package/lib/idn-confusables.mjs +5 -1
  66. package/lib/lookup-child-profile-contract.mjs +28 -11
  67. package/lib/lookup-collection-receipt.mjs +52 -0
  68. package/lib/lookup-diagnostics.mjs +2 -12
  69. package/lib/lookup-progress.mjs +29 -31
  70. package/lib/lookup-response-contract.mjs +10 -0
  71. package/lib/lookup-response.mjs +15 -14
  72. package/lib/lookup-source-progress.mjs +4 -2
  73. package/lib/lookup.mjs +33 -2
  74. package/lib/operation-budget.mjs +4 -0
  75. package/lib/rdap-capabilities.mjs +43 -9
  76. package/lib/registry-insights.mjs +17 -6
  77. package/lib/service-binding-dns.mjs +19 -2
  78. package/lib/sslbl-certificates.generated.mjs +142 -140
  79. package/lib/static-html-analysis.mjs +32 -4
  80. package/lib/technology-evidence-role.mjs +1 -51
  81. package/lib/tls-intelligence.mjs +30 -5
  82. package/lib/website-enrichment.mjs +39 -23
  83. package/lib/whois-authority.mjs +56 -1
  84. package/lib/whois-chain.mjs +14 -4
  85. package/lib/whois-common-formats.mjs +3 -1
  86. package/lib/whois-contacts.mjs +13 -2
  87. package/lib/whois-contracts.mjs +2 -0
  88. package/lib/whois-normalization.mjs +4 -4
  89. package/package.json +9 -7
  90. package/packages/analysis/bounded-json.mjs +12 -2
  91. package/packages/analysis/risk-scoring.mjs +12 -9
  92. package/packages/cases/case-attachment-model.mjs +36 -1
  93. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  94. package/packages/cases/case-containment-handoff.mjs +195 -0
  95. package/packages/cases/case-evidence-links.mjs +44 -0
  96. package/packages/cases/case-evidence-model.mjs +51 -7
  97. package/packages/cases/case-migration-model.mjs +9 -1
  98. package/packages/cases/case-object-coverage.mjs +119 -0
  99. package/packages/cases/case-packet-correction.mjs +106 -0
  100. package/packages/cases/case-recheck-model.mjs +35 -5
  101. package/packages/cases/case-record-operations.mjs +87 -9
  102. package/packages/cases/case-record-projection.mjs +43 -5
  103. package/packages/cases/case-record-version-input.mjs +23 -1
  104. package/packages/cases/case-report.mjs +35 -4
  105. package/packages/cases/case-requested-evidence.mjs +1 -1
  106. package/packages/cases/case-response-actions.mjs +57 -1
  107. package/packages/cases/case-response-model.mjs +7 -0
  108. package/packages/cases/case-response-object.mjs +119 -0
  109. package/packages/cases/case-response-outcomes.mjs +81 -14
  110. package/packages/cases/case-response-packet-render.mjs +22 -3
  111. package/packages/cases/case-response-packet-validation.mjs +880 -0
  112. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  113. package/packages/cases/case-response-packet.mjs +32 -17
  114. package/packages/cases/case-response-review-inputs.mjs +66 -8
  115. package/packages/cases/case-type-presentation.mjs +10 -0
  116. package/packages/cases/case-workflow-metadata.mjs +1 -17
  117. package/packages/comparison/capture-context.mjs +3 -2
  118. package/packages/contracts/analyst-interchange.mjs +7 -5
  119. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  120. package/packages/contracts/candidate-watch-review.mjs +54 -0
  121. package/packages/contracts/case-portability.mjs +292 -43
  122. package/packages/contracts/cli-command-semantics.mjs +3 -0
  123. package/packages/contracts/cli-doctor.mjs +4 -0
  124. package/packages/contracts/cli-lookup.mjs +57 -46
  125. package/packages/contracts/context-review.mjs +2 -1
  126. package/packages/contracts/external-observation-interchange.mjs +96 -28
  127. package/packages/contracts/lookup-evidence.mjs +13 -2
  128. package/packages/contracts/message-intake.mjs +7 -1
  129. package/packages/contracts/registry-insights.mjs +6 -0
  130. package/packages/contracts/risk-calibration.mjs +44 -29
  131. package/packages/contracts/schema-lifecycle.mjs +3 -1
  132. package/packages/contracts/web-capture.mjs +1 -1
  133. package/packages/contracts/workspace-portability.mjs +86 -16
  134. package/packages/evidence/collection-quality.mjs +1 -1
  135. package/packages/evidence/credential-form-attribution.mjs +60 -0
  136. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  137. package/packages/evidence/image-regions.mjs +15 -0
  138. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  139. package/packages/evidence/lookup-collection-review.mjs +74 -0
  140. package/packages/evidence/record-identity.mjs +60 -16
  141. package/packages/evidence/technology-evidence-role.mjs +51 -0
  142. package/packages/interchange/external-findings-import.mjs +40 -21
  143. package/packages/interchange/json-output.mjs +14 -0
  144. package/packages/interchange/managed-indicator-export.mjs +3 -2
  145. package/packages/interchange/managed-indicator-set.mjs +13 -0
  146. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  147. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  148. package/packages/investigation/context-review.mjs +3 -3
  149. package/packages/investigation/document-intake.mjs +9 -3
  150. package/packages/investigation/domain-history-review.mjs +50 -12
  151. package/packages/investigation/domain-transition-review.mjs +2 -0
  152. package/packages/investigation/infrastructure-observation.mjs +234 -0
  153. package/packages/investigation/intake-context.mjs +284 -0
  154. package/packages/investigation/intake-indicators.mjs +116 -0
  155. package/packages/investigation/intake-phones.mjs +155 -0
  156. package/packages/investigation/intake-report.mjs +2 -1
  157. package/packages/investigation/investigation-manifest.mjs +33 -7
  158. package/packages/investigation/link-intake.mjs +38 -8
  159. package/packages/investigation/message-intake.mjs +55 -15
  160. package/packages/monitoring/domain-feed.mjs +321 -0
  161. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  162. package/packages/workspace/brand-keyword-campaign.mjs +241 -0
  163. package/packages/workspace/brand-profile-model.mjs +46 -2
  164. package/packages/workspace/bulk-session-model.mjs +12 -0
  165. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  166. package/packages/workspace/detection-rule-model.mjs +10 -2
  167. package/packages/workspace/shortlist-model.mjs +10 -2
  168. package/packages/workspace/watchlist-history.mjs +31 -4
  169. package/packages/workspace/watchlist-store.mjs +136 -7
  170. package/third-party-notices.txt +30 -30
@@ -1,4 +1,3 @@
1
- // Response packet vocabulary shared by construction and independent validation.
2
1
  export const RESPONSE_CONTACT_KINDS = [
3
2
  'registrar',
4
3
  'registry',
@@ -6,6 +5,18 @@ export const RESPONSE_CONTACT_KINDS = [
6
5
  'security_txt',
7
6
  'application_platform',
8
7
  ];
8
+ const ACTION_CONTACT_KINDS = {
9
+ registrar_report: 'registrar',
10
+ registry_report: 'registry',
11
+ network_hosting_report: 'network_hosting',
12
+ security_contact_report: 'security_txt',
13
+ platform_report: 'application_platform',
14
+ defensive_control: 'manual',
15
+ internal_review: 'manual',
16
+ };
17
+ export function responseActionContactKind(type) {
18
+ return ACTION_CONTACT_KINDS[type];
19
+ }
9
20
  export const RESPONSE_PACKET_PROFILE_IDS = [
10
21
  'registrar',
11
22
  'registry',
@@ -1,8 +1,11 @@
1
1
  import { renderCaseResponsePacket } from "./case-response-packet-render.mjs";
2
- import { responseContactLabel as contactLabel } from "./case-response-packet-vocabulary.mjs";
2
+ import { responseActionContactKind, responseContactLabel as contactLabel } from "./case-response-packet-vocabulary.mjs";
3
3
  // Pure abuse-evidence packet builder. It creates local review artifacts only:
4
4
  // no network requests, mailto links, submissions, or provider side effects.
5
5
  import { assertPacketAmendmentSelection } from "./case-requested-evidence.mjs";
6
+ import { correctionDelivery, CASE_DELIVERY_RECEIPT_LIMITATION } from "./case-packet-correction.mjs";
7
+ import { caseSelectedEvidenceSourceLimitations } from "./case-evidence-links.mjs";
8
+ import { sameCaseResponseObject } from "./case-response-object.mjs";
6
9
  import { assertBoundedJsonStructure, } from "../analysis/bounded-json.mjs";
7
10
  import { CASE_RESPONSE_PACKET_SCHEMA, 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_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
8
11
  import { SORTED_JSON_V2, canonicalArtifactJsonV2, } from "../evidence/artifact-integrity.mjs";
@@ -199,19 +202,6 @@ function normalizeLimitations(value) {
199
202
  }
200
203
  return [...unique];
201
204
  }
202
- function actionContactKind(type) {
203
- if (type === 'registrar_report')
204
- return 'registrar';
205
- if (type === 'registry_report')
206
- return 'registry';
207
- if (type === 'network_hosting_report')
208
- return 'network_hosting';
209
- if (type === 'security_contact_report')
210
- return 'security_txt';
211
- if (type === 'platform_report')
212
- return 'application_platform';
213
- return 'manual';
214
- }
215
205
  function selectedPacketAction(caseRecord, input) {
216
206
  const actionId = typeof input.actionId === 'string' && /^[A-Za-z0-9_-]{1,64}$/u.test(input.actionId)
217
207
  ? input.actionId
@@ -222,6 +212,9 @@ function assertAmendmentEvidence(caseRecord, input) {
222
212
  const action = selectedPacketAction(caseRecord, input);
223
213
  const selected = new Set(Array.isArray(input.selectedEvidencePinIds) ? input.selectedEvidencePinIds : []);
224
214
  assertPacketAmendmentSelection(caseRecord.actions, action?.id ?? null, new Set(caseRecord.evidencePins.filter(pin => selected.has(pin.id)).map(pin => pin.id)));
215
+ if (action?.correction)
216
+ correctionDelivery(caseRecord.actions, action, { caseId: caseRecord.id, target: caseRecord.domain,
217
+ profile: responsePacketProfile(input.profile).id, selectedPinIds: new Set(caseRecord.evidencePins.filter(pin => selected.has(pin.id)).map(pin => pin.id)) });
225
218
  }
226
219
  function packetActionLineage(caseRecord, input) {
227
220
  const selected = selectedPacketAction(caseRecord, input);
@@ -258,7 +251,7 @@ function bindPacketRoute(caseRecord, input, generatedAt) {
258
251
  contacts: [],
259
252
  };
260
253
  }
261
- const kind = actionContactKind(action.type);
254
+ const kind = responseActionContactKind(action.type);
262
255
  const compatible = profile.requiredContactKind
263
256
  ? kind === profile.requiredContactKind
264
257
  : profile.id === 'browser_blocklist'
@@ -322,6 +315,7 @@ function normalizeSelectedEvidence(caseRecord, value) {
322
315
  label: text(pin.label, 80),
323
316
  source: text(pin.source, 120),
324
317
  ...(pin.observationHostname ? { observationHostname: pin.observationHostname } : {}),
318
+ ...(pin.responseObject ? { responseObject: structuredClone(pin.responseObject) } : {}),
325
319
  ...(pin.webObservationMode ? { webObservationMode: pin.webObservationMode } : {}),
326
320
  observedAt: timestamp(pin.observedAt),
327
321
  completeness: text(pin.completeness, 40),
@@ -524,7 +518,7 @@ export function buildResponsePacketProfilePreview(caseRecord, input) {
524
518
  id: profile.id,
525
519
  label: profile.label,
526
520
  audience: profile.audience,
527
- subject: `${profile.subjectPrefix}: ${caseRecord.domain} (${category})`,
521
+ subject: `${selectedPacketAction(caseRecord, input)?.correction ? 'Correction or retraction request' : profile.subjectPrefix}: ${caseRecord.domain} (${category})`,
528
522
  checklist: [...profile.checklist],
529
523
  evidenceOrder: [...profile.evidenceOrder],
530
524
  includedEvidence: [...profile.includedEvidence],
@@ -687,6 +681,8 @@ function normalizeActionHistory(caseRecord, input) {
687
681
  actionId: action.id,
688
682
  type: text(action.type, 80),
689
683
  ...(action.amendment ? { amendment: structuredClone(action.amendment) } : {}),
684
+ ...(action.correction ? { correction: structuredClone(action.correction) } : {}),
685
+ ...(action.responseObjects !== undefined ? { responseObjects: structuredClone(action.responseObjects) } : {}),
690
686
  recipient: text(action.recipient, 320),
691
687
  contactSource: text(action.contactSource, 120),
692
688
  routeObservedAt: timestamp(action.routeObservedAt),
@@ -701,6 +697,9 @@ function normalizeActionHistory(caseRecord, input) {
701
697
  transitions: action.history.map((event) => ({
702
698
  id: event.id,
703
699
  ...(event.evidenceRequest ? { evidenceRequest: structuredClone(event.evidenceRequest) } : {}),
700
+ ...(event.packetReceipt ? { packetReceipt: structuredClone(event.packetReceipt) } : {}),
701
+ ...(event.responseObjects !== undefined ? { responseObjects: structuredClone(event.responseObjects) } : {}),
702
+ ...(event.objectOutcome ? { objectOutcome: event.objectOutcome } : {}),
704
703
  previousState: event.previousState,
705
704
  nextState: event.nextState,
706
705
  occurredAt: event.occurredAt,
@@ -736,21 +735,31 @@ function normalizeActionHistory(caseRecord, input) {
736
735
  }
737
736
  function normalizeResponseLifecycle(caseRecord, actionIds, lineageComplete) {
738
737
  const scopedIds = new Set(actionIds);
738
+ const objects = caseRecord.actions.filter(action => scopedIds.has(action.id)).flatMap(action => action.responseObjects ?? []);
739
739
  const scopedRecord = {
740
740
  ...caseRecord,
741
741
  actions: caseRecord.actions.filter((action) => scopedIds.has(action.id)),
742
+ observedEffects: {
743
+ ...caseRecord.observedEffects,
744
+ reviews: caseRecord.observedEffects.reviews.filter(review => review.responseObject
745
+ && objects.some(object => sameCaseResponseObject(object, review.responseObject))),
746
+ },
742
747
  closures: {
743
748
  ...caseRecord.closures,
744
749
  records: caseRecord.closures.records.filter((closure) => closure.actionId !== null && scopedIds.has(closure.actionId)),
745
750
  },
746
751
  };
747
752
  const summary = buildCaseResponseLifecycleSummary(scopedRecord);
753
+ const latestReview = scopedRecord.observedEffects.reviews.find(review => review.id === summary.latestObservedEffect?.reviewId);
748
754
  const providerOutcomeState = lineageComplete ? summary.providerOutcomeState : 'ambiguous';
749
755
  return {
750
756
  providerOutcomeState,
751
757
  latestProviderOutcome: lineageComplete && summary.latestProviderOutcome ? { ...summary.latestProviderOutcome } : null,
752
758
  observedChangeState: summary.observedChangeState,
753
- latestObservedEffect: summary.latestObservedEffect ? { ...summary.latestObservedEffect } : null,
759
+ latestObservedEffect: summary.latestObservedEffect ? { ...summary.latestObservedEffect,
760
+ ...(latestReview?.responseObject ? { responseObject: latestReview.responseObject } : {}),
761
+ ...(latestReview?.objectOutcome ? { objectOutcome: latestReview.objectOutcome } : {}),
762
+ } : null,
754
763
  latestObservedChangeAt: summary.latestObservedChangeAt,
755
764
  closure: summary.latestClosure ? {
756
765
  id: summary.latestClosure.id,
@@ -762,6 +771,8 @@ function normalizeResponseLifecycle(caseRecord, actionIds, lineageComplete) {
762
771
  'Provider workflow outcomes and independently observed technical effects are separate point-in-time records.',
763
772
  'A provider acknowledgement, terminal state, or reported resolution never becomes independently observed remediation, absence, or safety.',
764
773
  'Times are withheld when the corresponding typed event is missing or ambiguous.',
774
+ ...(objects.length ? ['Each explicit object remains independent; a latest review does not adjudicate every object bound to the action.'] : []),
775
+ ...(caseRecord.observedEffects.reviews.length > scopedRecord.observedEffects.reviews.length ? ['Independent reviews without matching explicit action-object scope were excluded from this packet; their retained Case history is unchanged.'] : []),
765
776
  ...(!summary.latestObservedEffect && scopedRecord.observedEffects.reviews.length ? ['A single latest independent review cannot be selected from the retained observation times; all retained reviews remain separately attributed.'] : []),
766
777
  ...(!lineageComplete ? ['Provider-outcome time is withheld because the selected action origin lineage is incomplete.'] : []),
767
778
  ],
@@ -797,6 +808,7 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
797
808
  return {
798
809
  contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
799
810
  version: CASE_RESPONSE_REVIEW_INPUTS_VERSION,
811
+ sourceQualifications: caseSelectedEvidenceSourceLimitations(caseRecord.evidencePins, caseRecord.evidenceLinks, selectedEvidence.map(pin => pin.id)),
800
812
  profile: {
801
813
  id: profile.id,
802
814
  label: profile.label,
@@ -936,6 +948,9 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
936
948
  ? 'This packet is bound to explicit confirmations for the exact reviewed-input digest. It still requires deliberate manual use.'
937
949
  : 'This packet is a local draft with cautions and is not authorised for external use by WHOISleuth.',
938
950
  'WHOISleuth did not submit this packet or verify that any listed contact is monitored.',
951
+ ...reviewMaterial.sourceQualifications,
952
+ ...(selectedPacketAction(caseRecord, input)?.correction ? [CASE_DELIVERY_RECEIPT_LIMITATION,
953
+ 'This is a new correction or retraction request. It does not undo the original delivery, establish recipient acceptance or restoration, or replace independent recheck.'] : []),
939
954
  ...(!contacts.length ? ['No escalation contact was included.'] : []),
940
955
  ...(age.refreshRecommended ? ['The selected observation is over seven days old or appears to be in the future. Refresh evidence before submission.'] : []),
941
956
  ];
@@ -1,9 +1,12 @@
1
1
  // Exact, bounded validation of current and published response-review inputs.
2
2
  import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertPacketAmendmentSelection } from "./case-requested-evidence.mjs";
3
+ import { readCaseDeliveryPacketReceipt, readCasePacketCorrection, assertDeliveryPacketReceipt, correctionDelivery } from "./case-packet-correction.mjs";
4
+ import { CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION } from "./case-evidence-links.mjs";
3
5
  import { CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_CASE_ACTIONS, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, MAX_RESPONSE_VALUE_LENGTH, PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION, SUPPORTED_CASE_RESPONSE_REVIEW_INPUTS_VERSIONS, } from "../contracts/case-portability.mjs";
4
6
  import { 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-records.mjs";
5
- import { RESPONSE_CONTACT_KINDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_ROW_IDS, RESPONSE_READINESS_STATES, } from "./case-response-packet-vocabulary.mjs";
7
+ import { RESPONSE_CONTACT_KINDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_ROW_IDS, RESPONSE_READINESS_STATES, responseActionContactKind, } from "./case-response-packet-vocabulary.mjs";
6
8
  import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
9
+ import { readCaseResponseObject, readCaseResponseObjects, readCaseResponseObjectOutcome, assertCaseObjectOutcome } from "./case-response-object.mjs";
7
10
  const CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS);
8
11
  const PRE_PLATFORM_CONTACT_KINDS = new Set(RESPONSE_CONTACT_KINDS.filter((kind) => kind !== 'application_platform'));
9
12
  const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
@@ -27,6 +30,7 @@ const CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze([
27
30
  'escalationHistoryOmitted',
28
31
  'escalationHistoryLimitations',
29
32
  'responseLifecycle',
33
+ 'sourceQualifications',
30
34
  ]);
31
35
  const PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS = Object.freeze(CASE_RESPONSE_REVIEW_INPUT_KEYS.filter((key) => key !== 'recipientRoute' && key !== 'actionBinding'));
32
36
  function exactReviewRecord(value, keys, label, optionalKeys = []) {
@@ -88,7 +92,7 @@ function reviewEnum(value, values, label) {
88
92
  function reviewNullableEnum(value, values, label) {
89
93
  return value === null ? null : reviewEnum(value, values, label);
90
94
  }
91
- function validateReviewLifecycle(value) {
95
+ function validateReviewLifecycle(value, version) {
92
96
  const lifecycle = exactReviewRecord(value, [
93
97
  'providerOutcomeState', 'latestProviderOutcome', 'observedChangeState',
94
98
  'latestObservedEffect', 'latestObservedChangeAt', 'closure', 'limitations',
@@ -110,7 +114,10 @@ function validateReviewLifecycle(value) {
110
114
  if (lifecycle.latestObservedEffect !== null) {
111
115
  const effect = exactReviewRecord(lifecycle.latestObservedEffect, [
112
116
  'reviewId', 'state', 'observedAt', 'sourceClass', 'source',
113
- ], 'Case-response latest observed effect');
117
+ ], 'Case-response latest observed effect', version >= 6 ? ['responseObject', 'objectOutcome'] : []);
118
+ const responseObject = readCaseResponseObject(effect.responseObject);
119
+ const objectOutcome = readCaseResponseObjectOutcome(effect.objectOutcome);
120
+ assertCaseObjectOutcome(objectOutcome, responseObject);
114
121
  reviewText(effect.reviewId, 64, 'Case-response observed-effect review id');
115
122
  reviewEnum(effect.state, CASE_OBSERVED_EFFECT_STATES, 'Case-response observed-effect state');
116
123
  reviewText(effect.observedAt, 64, 'Case-response observed-effect time');
@@ -135,6 +142,21 @@ function recursivelyFreezeReviewValue(value) {
135
142
  }
136
143
  return Object.freeze(value);
137
144
  }
145
+ /** Compare already shape-validated route and action records without rebuilding
146
+ * or normalising the retained evidence. Older formats omit the review deadline. */
147
+ export function assertCaseResponseRecipientAction(route, actions, hasReviewAfter) {
148
+ if (!route)
149
+ return;
150
+ const action = actions.find(candidate => candidate.actionId === route.actionId);
151
+ if (!action
152
+ || route.kind !== responseActionContactKind(action.type)
153
+ || route.contact !== action.recipient
154
+ || route.source !== (action.contactSource || 'analyst supplied')
155
+ || route.observedAt !== action.routeObservedAt
156
+ || (hasReviewAfter && route.reviewAfter !== action.routeReviewAfter)) {
157
+ throw new TypeError('Case-response recipient route must match its selected action.');
158
+ }
159
+ }
138
160
  export function validateCaseResponseReviewInputs(value) {
139
161
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
140
162
  throw new TypeError('Case-response review inputs must be an exact object.');
@@ -151,7 +173,12 @@ export function validateCaseResponseReviewInputs(value) {
151
173
  }
152
174
  const hasPlatformRoutes = version >= PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION;
153
175
  const hasActionBinding = version >= PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION;
154
- const source = exactReviewRecord(value, hasActionBinding ? CASE_RESPONSE_REVIEW_INPUT_KEYS : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS, 'Case-response review inputs');
176
+ const source = exactReviewRecord(value, (hasActionBinding ? CASE_RESPONSE_REVIEW_INPUT_KEYS : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUT_KEYS).filter(key => version >= 7 || key !== 'sourceQualifications'), 'Case-response review inputs');
177
+ if (version >= 7)
178
+ for (const qualification of boundedReviewArray(source.sourceQualifications, 1, 'Selected evidence source qualifications')) {
179
+ if (qualification !== CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION)
180
+ throw new TypeError('Unknown selected-source qualification.');
181
+ }
155
182
  const profile = exactReviewRecord(source.profile, [
156
183
  'id', 'label', 'audience', 'subject', 'checklist', 'includedEvidence',
157
184
  'excludedEvidence', 'redactions',
@@ -236,7 +263,11 @@ export function validateCaseResponseReviewInputs(value) {
236
263
  for (const candidate of boundedReviewArray(source.selectedEvidence, MAX_RESPONSE_SELECTED_EVIDENCE, 'Case-response selected evidence')) {
237
264
  const evidence = exactReviewRecord(candidate, [
238
265
  'id', 'label', 'source', 'observedAt', 'completeness', 'limitations',
239
- ], 'Case-response selected evidence item', version > PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION ? ['observationHostname', 'webObservationMode'] : []);
266
+ ], 'Case-response selected evidence item', [
267
+ ...(version > PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION ? ['observationHostname', 'webObservationMode'] : []),
268
+ ...(version >= 6 ? ['responseObject'] : []),
269
+ ]);
270
+ readCaseResponseObject(evidence.responseObject);
240
271
  reviewText(evidence.id, 64, 'Case-response evidence id');
241
272
  reviewText(evidence.label, 80, 'Case-response evidence label');
242
273
  reviewText(evidence.source, 120, 'Case-response evidence source');
@@ -319,8 +350,11 @@ export function validateCaseResponseReviewInputs(value) {
319
350
  ...(hasPlatformRoutes ? ['routeReviewAfter'] : []),
320
351
  'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
321
352
  'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
322
- ], 'Case-response escalation action', version >= 5 ? ['amendment'] : []);
353
+ ], 'Case-response escalation action', [...(version >= 5 ? ['amendment'] : []), ...(version >= 6 ? ['responseObjects'] : []), ...(version >= 7 ? ['correction'] : [])]);
354
+ readCaseResponseObjects(action.responseObjects);
323
355
  readCasePacketAmendment(action.amendment);
356
+ if (readCasePacketCorrection(action.correction) && action.amendment)
357
+ throw new TypeError('Correction and provider amendment must remain separate.');
324
358
  for (const key of ['actionId', 'recipient', 'contactSource', 'createdAt', 'updatedAt']) {
325
359
  reviewText(action[key], MAX_RESPONSE_VALUE_LENGTH, `Case-response action ${key}`);
326
360
  }
@@ -346,7 +380,21 @@ export function validateCaseResponseReviewInputs(value) {
346
380
  'id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance',
347
381
  'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail',
348
382
  'originActionId', 'applied',
349
- ], 'Case-response transition', version >= 5 ? ['evidenceRequest'] : []);
383
+ ], 'Case-response transition', [...(version >= 5 ? ['evidenceRequest'] : []), ...(version >= 6 ? ['responseObjects', 'objectOutcome'] : []), ...(version >= 7 ? ['packetReceipt'] : [])]);
384
+ const receipt = readCaseDeliveryPacketReceipt(transition.packetReceipt);
385
+ assertDeliveryPacketReceipt(receipt, transition, action.actionId);
386
+ if (receipt && (receipt.caseId !== source.case.id || receipt.target !== source.case.domain
387
+ || receipt.recipient !== action.recipient))
388
+ throw new TypeError('The delivery receipt must retain the same Case, target and recipient.');
389
+ const responseObjects = readCaseResponseObjects(transition.responseObjects);
390
+ const objectOutcome = readCaseResponseObjectOutcome(transition.objectOutcome);
391
+ if (objectOutcome) {
392
+ if (!responseObjects?.length || !['submitted', 'acknowledged', 'terminal'].includes(String(transition.nextState))
393
+ || !['analyst', 'provider'].includes(String(transition.sourceClass))
394
+ || transition.previousState === 'authorised')
395
+ throw new TypeError('Case-response object outcome requires an explicit affected subset after submission.');
396
+ responseObjects.forEach(object => assertCaseObjectOutcome(objectOutcome, object));
397
+ }
350
398
  const evidenceRequest = readCaseEvidenceRequest(transition.evidenceRequest);
351
399
  assertEvidenceRequestEvent(evidenceRequest, transition);
352
400
  requestEvents.push({ id: transition.id, ...(evidenceRequest ? { evidenceRequest } : {}) });
@@ -371,12 +419,22 @@ export function validateCaseResponseReviewInputs(value) {
371
419
  || escalationActionIds.some((id, index) => currentLineageActionIds?.[index] !== id))) {
372
420
  throw new TypeError('Case-response escalation history must match the selected action lineage in order.');
373
421
  }
422
+ if (hasActionBinding)
423
+ assertCaseResponseRecipientAction(source.recipientRoute, source.escalationHistory, hasPlatformRoutes);
374
424
  reviewCount(source.escalationHistoryOmitted, MAX_CASE_ACTIONS, 'Case-response omitted action count');
375
425
  if (version >= 5)
376
426
  assertPacketAmendmentSelection(source.escalationHistory.map(action => ({
377
427
  id: action.actionId, history: action.transitions, originActionId: action.originActionId, amendment: action.amendment,
378
428
  })), currentLineageActionIds?.[0] ?? null, new Set(source.selectedEvidence.map(pin => pin.id)));
429
+ if (version >= 7) {
430
+ const actions = source.escalationHistory.map(action => ({ ...action, id: action.actionId, history: action.transitions }));
431
+ const selected = actions.find(action => action.id === currentLineageActionIds?.[0]);
432
+ if (selected?.correction)
433
+ correctionDelivery(actions, selected, { caseId: source.case.id,
434
+ target: source.case.domain, profile: profile.id,
435
+ selectedPinIds: new Set(source.selectedEvidence.map(pin => pin.id)) });
436
+ }
379
437
  reviewStrings(source.escalationHistoryLimitations, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH, 'Case-response escalation limitations');
380
- validateReviewLifecycle(source.responseLifecycle);
438
+ validateReviewLifecycle(source.responseLifecycle, version);
381
439
  return recursivelyFreezeReviewValue(structuredClone(source));
382
440
  }
@@ -0,0 +1,10 @@
1
+ import { caseTypeRecords } from "./case-workflow-metadata.mjs";
2
+ /** Display text for declared Case types; admission and mutation remain separate. */
3
+ export function caseTypeSummary(record) {
4
+ const labels = caseTypeRecords(record).map((item) => item.label);
5
+ if (!labels.length)
6
+ return '';
7
+ if (labels.length <= 2)
8
+ return labels.join(' and ');
9
+ return `${labels.slice(0, 2).join(', ')} and ${labels.length - 2} more`;
10
+ }
@@ -44,14 +44,6 @@ export function caseTypeRecords(record) {
44
44
  const selected = new Set(caseTypeIds(record));
45
45
  return CASE_TYPES.filter((item) => selected.has(item.id));
46
46
  }
47
- export function caseTypeSummary(record) {
48
- const labels = caseTypeRecords(record).map((item) => item.label);
49
- if (!labels.length)
50
- return '';
51
- if (labels.length <= 2)
52
- return labels.join(' and ');
53
- return `${labels.slice(0, 2).join(', ')} and ${labels.length - 2} more`;
54
- }
55
47
  function encodeCaseReference(value, minimumLength) {
56
48
  let remainder = value;
57
49
  let encoded = '';
@@ -102,15 +94,7 @@ export function normalizeCaseIncidentTargetUrl(value) {
102
94
  const normalized = parsed.toString();
103
95
  return normalized.length <= MAX_CASE_INCIDENT_TARGET_URL_LENGTH ? normalized : null;
104
96
  }
105
- /** Local review only: analyst link state is not independent observed coverage. */
106
- export function buildCaseIncidentCoverage(record) {
107
- return caseIncidentTargets(record, { includeResolved: true }).map(target => ({
108
- target,
109
- hostname: new URL(target.url).hostname,
110
- actionCoverage: 'unknown',
111
- observationCoverage: 'unknown',
112
- }));
113
- }
97
+ export { buildCaseIncidentCoverage } from "./case-object-coverage.mjs";
114
98
  export function emptyCaseWorkflowMetadata() {
115
99
  return { types: [], incidentTargets: [], investigationContext: null };
116
100
  }
@@ -1,6 +1,7 @@
1
1
  import { exact, text } from "../evidence/artifact-structure.mjs";
2
2
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
3
  import { readEvidenceImageDimensions } from "../evidence/image-regions.mjs";
4
+ export const MAX_CAPTURE_COMPARISON_CONTEXTS = 20;
4
5
  /** Optional declarations remain unknown for existing manifests; no inference from image dimensions. */
5
6
  export function readCaptureConditions(raw) {
6
7
  if (raw === undefined || raw === null)
@@ -23,8 +24,8 @@ export function readObservationLabel(raw) {
23
24
  return text(raw, 'Observation label', 80);
24
25
  }
25
26
  export function compareObservationContexts(input) {
26
- if (!Array.isArray(input) || input.length < 2 || input.length > 20)
27
- throw new TypeError('Compare between 2 and 20 observation contexts.');
27
+ if (!Array.isArray(input) || input.length < 2 || input.length > MAX_CAPTURE_COMPARISON_CONTEXTS)
28
+ throw new TypeError(`Compare between 2 and ${MAX_CAPTURE_COMPARISON_CONTEXTS} observation contexts.`);
28
29
  const observations = input.map(value => {
29
30
  const observedAt = value.observedAt === null ? null : normalizeExplicitIsoTimestamp(value.observedAt);
30
31
  if (value.observedAt !== null && !observedAt)
@@ -33,7 +33,8 @@ export const MAIL_REPORT_CANONICALIZATION_ROUTES = Object.freeze([
33
33
  export const REGISTRATION_DISCLOSURE_PLAN_SCHEMA = 'whoisleuth.registration-disclosure-plan';
34
34
  export const REGISTRATION_DISCLOSURE_PLAN_VERSION = 2;
35
35
  export const STATIC_PAGE_PATTERN_PACK_SCHEMA = 'whoisleuth.static-page-pattern-pack';
36
- export const STATIC_PAGE_PATTERN_PACK_VERSION = 2;
36
+ export const STATIC_PAGE_PATTERN_PACK_VERSION = 3;
37
+ export const STATIC_PAGE_PATTERN_PACK_SUPPORTED_VERSIONS = [2, STATIC_PAGE_PATTERN_PACK_VERSION];
37
38
  export const MAX_STATIC_PAGE_PATTERN_PACK_BYTES = 256 * 1024;
38
39
  export const WEB_CAPTURE_SUMMARY_SCHEMA = 'whoisleuth.web-capture-summary';
39
40
  export const WEB_CAPTURE_SUMMARY_VERSION = 1;
@@ -136,11 +137,11 @@ const REGISTRATION_DISCLOSURE_COMPATIBILITY = defineSchemaCompatibility({
136
137
  });
137
138
  const STATIC_PATTERN_PACK_COMPATIBILITY = defineSchemaCompatibility({
138
139
  id: 'export.static-page-pattern-pack', kind: 'export', schema: STATIC_PAGE_PATTERN_PACK_SCHEMA,
139
- currentVersion: STATIC_PAGE_PATTERN_PACK_VERSION, supportedVersions: [STATIC_PAGE_PATTERN_PACK_VERSION],
140
- acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'exact_current_only',
140
+ currentVersion: STATIC_PAGE_PATTERN_PACK_VERSION, supportedVersions: STATIC_PAGE_PATTERN_PACK_SUPPORTED_VERSIONS,
141
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'read_only',
141
142
  writeSemantics: 'non_destructive_merge', byteBudget: MAX_STATIC_PAGE_PATTERN_PACK_BYTES,
142
143
  owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
143
- note: 'Exact-current bounded page-pattern pack imported by explicit analyst action and merged without executing code or starting collection.',
144
+ note: 'Versions 2 and 3 remain readable. Version 3 can use attributed password-form destinations; older rules retain their declared meaning. Import remains explicit and does not execute code or start collection.',
144
145
  });
145
146
  export function serialiseAnalystInterchangeJson(value) {
146
147
  return `${JSON.stringify(value, null, 2)}\n`;
@@ -229,6 +230,7 @@ export const ANALYST_INTERCHANGE_LIFECYCLE_FAMILY = defineSchemaLifecycleFamily(
229
230
  { descriptor: STATIC_PATTERN_PACK_COMPATIBILITY, lifecycleSchema: STATIC_PAGE_PATTERN_PACK_SCHEMA,
230
231
  requiredKeys: ['schema', 'version', 'id', 'label', 'description', 'evidenceBoundary', 'relationship', 'confidence', 'rules'], optionalKeys: [],
231
232
  hook: { module: 'packages/interchange/static-page-pattern-packs.mts', exportName: 'validateStaticPagePatternPack', role: 'structure_validator', runtime: 'shared' },
232
- fixtures: [{ id: 'static-page-pattern-pack-v2', path: `${F}static-page-pattern-pack-v2.json`, bytes: 1_094, sha256: 'c409e527850bcb924f5be0708a58c5e2c32d98947c42150f6a79765991c0eb6f', version: STATIC_PAGE_PATTERN_PACK_VERSION }] },
233
+ fixtures: [{ id: 'static-page-pattern-pack-v2', path: `${F}static-page-pattern-pack-v2.json`, bytes: 1_094, sha256: 'c409e527850bcb924f5be0708a58c5e2c32d98947c42150f6a79765991c0eb6f', version: 2 },
234
+ { id: 'static-page-pattern-pack-v3', path: `${F}static-page-pattern-pack-v3.json`, bytes: 746, sha256: '5be19b621ad604de8c430191a34fcd2d624b22b8bdf9a5cb2653ef7d1b86c71a', version: 3 }] },
233
235
  ],
234
236
  }));
@@ -59,7 +59,7 @@ export const ANALYST_REVIEW_NEXT_ACTIONS = ['review', 'refresh', 'follow_up', 'r
59
59
  export const ANALYST_REVIEW_LIFECYCLE_STATES = ['open', 'expected', 'suppressed', 'resolved', 'expired', 'invalidated', 'recurred', 'orphaned'];
60
60
  export const ANALYST_REVIEW_QUEUE_OPTIONS = [
61
61
  { value: 'needs_action', label: 'Needs action' },
62
- { value: 'waiting', label: 'Waiting / follow-up' },
62
+ { value: 'waiting', label: 'Waiting on' },
63
63
  { value: 'changed', label: 'Changed since review' },
64
64
  { value: 'reviewed', label: 'Reviewed' },
65
65
  { value: 'all', label: 'Everything' },
@@ -0,0 +1,54 @@
1
+ import { defineSchemaCompatibility } from "./schema-compatibility.mjs";
2
+ import { defineSchemaLifecycleFamily } from "./schema-lifecycle.mjs";
3
+ import { buildExtractedLifecycleFamily } from "./extracted-domain-lifecycle.mjs";
4
+ import { MAX_WATCHLIST_IMPORT_BYTES } from "./workspace-portability.mjs";
5
+ export const CANDIDATE_WATCH_INPUT_SCHEMA = 'whoisleuth.candidate-watch-input';
6
+ export const CANDIDATE_WATCH_INPUT_VERSION = 1;
7
+ export const CANDIDATE_WATCH_INPUT_COMPATIBILITY = defineSchemaCompatibility({
8
+ id: 'cli.candidate-watch-input',
9
+ kind: 'cli_document',
10
+ schema: CANDIDATE_WATCH_INPUT_SCHEMA,
11
+ currentVersion: 1,
12
+ supportedVersions: [1],
13
+ acceptsUnversionedLegacy: false,
14
+ futureVersionBehavior: 'reject',
15
+ migration: 'exact_current_only',
16
+ writeSemantics: 'read_only',
17
+ byteBudget: MAX_WATCHLIST_IMPORT_BYTES,
18
+ owner: 'packages/contracts/candidate-watch-review.mts',
19
+ note: 'Bounded offline analyst selection and optional portable Watchlist document; no collection or schedule authority.',
20
+ });
21
+ export const CANDIDATE_WATCH_REVIEW_LIFECYCLE_FAMILY = defineSchemaLifecycleFamily(buildExtractedLifecycleFamily({
22
+ id: 'candidate-watch-review',
23
+ owner: 'packages/contracts/candidate-watch-review.mts',
24
+ plane: 'node',
25
+ projection: 'review_output',
26
+ retention: 'transient_report',
27
+ serializerModule: 'cli/formatters/json.mts',
28
+ serializerExportName: 'formatJsonDocument',
29
+ includedCategories: ['candidate-provenance', 'analyst-watch-reasons', 'review-priorities'],
30
+ excludedCategories: ['raw-upstream-payloads', 'credentials', 'cookies', 'query-bearing-urls'],
31
+ formats: [
32
+ {
33
+ descriptor: CANDIDATE_WATCH_INPUT_COMPATIBILITY,
34
+ lifecycleSchema: CANDIDATE_WATCH_INPUT_SCHEMA,
35
+ requiredKeys: ['schema', 'version', 'watchlists', 'selection'],
36
+ optionalKeys: [],
37
+ hook: {
38
+ module: 'cli/watchlist-review.mts',
39
+ exportName: 'parseCandidateWatchInput',
40
+ role: 'structure_validator',
41
+ runtime: 'node',
42
+ },
43
+ fixtures: [
44
+ {
45
+ id: 'candidate-watch-input-v1',
46
+ path: 'test/fixtures/brand-candidate-workflow/candidate-watch-input-v1.json',
47
+ bytes: 895,
48
+ sha256: '40a0d73e5aa3afc3b6836bb289c79cf9556dd755a5086a9666506b31468b5d3d',
49
+ version: 1,
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ }));