@slicedearth/whoisleuth-cli 2.6.0 → 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 (84) hide show
  1. package/DISCLOSURE +8 -5
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -857
  5. package/cli/artifact-validation/lookup-evidence.mjs +15 -2
  6. package/cli/artifact-verify.mjs +32 -1
  7. package/cli/case-command.mjs +17 -17
  8. package/cli/case-pack.mjs +7 -1
  9. package/cli/ci-report.mjs +17 -5
  10. package/cli/command-definition.mjs +5 -5
  11. package/cli/doctor.mjs +37 -3
  12. package/cli/formatters/html.mjs +3 -0
  13. package/cli/formatters/json.mjs +8 -1
  14. package/cli/formatters/terminal-lookup.mjs +24 -1
  15. package/cli/history-command-definitions.mjs +1 -1
  16. package/cli/investigation-plan.mjs +15 -0
  17. package/cli/lookup-brief.mjs +11 -5
  18. package/cli/lookup-command-runner.mjs +20 -13
  19. package/cli/review-command-definitions.mjs +11 -11
  20. package/cli/saved-lookup.mjs +22 -7
  21. package/cli/source-reliability.mjs +12 -10
  22. package/cli/support-arguments.mjs +1 -1
  23. package/cli/support-command-definitions.mjs +5 -4
  24. package/cli/support-command-runner.mjs +6 -4
  25. package/cli/workflow-command-definitions.mjs +2 -1
  26. package/docs/cli-reference.md +49 -11
  27. package/docs/cli.md +109 -32
  28. package/docs/contextual-reviews.md +10 -0
  29. package/docs/infrastructure-observations.md +9 -0
  30. package/lib/dmarc-discovery.mjs +4 -2
  31. package/lib/dns-inheritance-review.mjs +14 -1
  32. package/lib/evidence-export.mjs +10 -4
  33. package/lib/evidence-report.mjs +2 -1
  34. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  35. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  36. package/lib/generated/retire-browser-catalog.mjs +178 -5
  37. package/lib/idn-confusables.mjs +5 -1
  38. package/lib/lookup-collection-receipt.mjs +52 -0
  39. package/lib/lookup-diagnostics.mjs +2 -12
  40. package/lib/lookup-progress.mjs +29 -31
  41. package/lib/lookup-response-contract.mjs +9 -0
  42. package/lib/lookup-response.mjs +15 -14
  43. package/lib/lookup-source-progress.mjs +4 -2
  44. package/lib/lookup.mjs +33 -2
  45. package/lib/rdap-capabilities.mjs +43 -9
  46. package/lib/registry-insights.mjs +17 -6
  47. package/lib/sslbl-certificates.generated.mjs +143 -142
  48. package/lib/whois-authority.mjs +56 -1
  49. package/lib/whois-chain.mjs +2 -1
  50. package/lib/whois-contracts.mjs +2 -0
  51. package/lib/whois-normalization.mjs +4 -4
  52. package/package.json +6 -6
  53. package/packages/analysis/bounded-json.mjs +12 -2
  54. package/packages/cases/case-attachment-model.mjs +36 -1
  55. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  56. package/packages/cases/case-evidence-links.mjs +28 -0
  57. package/packages/cases/case-evidence-model.mjs +39 -6
  58. package/packages/cases/case-migration-model.mjs +9 -1
  59. package/packages/cases/case-object-coverage.mjs +106 -20
  60. package/packages/cases/case-record-operations.mjs +13 -0
  61. package/packages/cases/case-record-projection.mjs +28 -2
  62. package/packages/cases/case-report.mjs +5 -4
  63. package/packages/cases/case-response-packet-validation.mjs +880 -0
  64. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  65. package/packages/cases/case-response-packet.mjs +2 -15
  66. package/packages/cases/case-response-review-inputs.mjs +18 -1
  67. package/packages/cases/case-type-presentation.mjs +10 -0
  68. package/packages/cases/case-workflow-metadata.mjs +0 -8
  69. package/packages/comparison/capture-context.mjs +3 -2
  70. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  71. package/packages/contracts/case-portability.mjs +59 -28
  72. package/packages/contracts/cli-doctor.mjs +4 -0
  73. package/packages/contracts/cli-lookup.mjs +57 -46
  74. package/packages/contracts/lookup-evidence.mjs +12 -2
  75. package/packages/contracts/registry-insights.mjs +6 -0
  76. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  77. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  78. package/packages/evidence/lookup-collection-review.mjs +74 -0
  79. package/packages/interchange/external-findings-import.mjs +8 -4
  80. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  81. package/packages/investigation/domain-history-review.mjs +7 -1
  82. package/packages/workspace/brand-keyword-campaign.mjs +11 -1
  83. package/packages/workspace/bulk-session-model.mjs +8 -0
  84. package/third-party-notices.txt +29 -29
@@ -2,9 +2,12 @@ import { defineSchemaCompatibility } from "./schema-compatibility.mjs";
2
2
  import { defineSchemaLifecycleFamily } from "./schema-lifecycle.mjs";
3
3
  export const CLI_LOOKUP_SCHEMA = 'whoisleuth.cli.lookup';
4
4
  export const PUBLIC_CLI_LOOKUP_VERSION = 1;
5
- export const CLI_LOOKUP_VERSION = 2;
5
+ export const HOMEPAGE_CLI_LOOKUP_VERSION = 2;
6
+ export const COLLECTION_RECEIPT_CLI_LOOKUP_VERSION = 3;
7
+ export const CLI_LOOKUP_VERSION = COLLECTION_RECEIPT_CLI_LOOKUP_VERSION;
6
8
  export const SUPPORTED_CLI_LOOKUP_VERSIONS = Object.freeze([
7
9
  PUBLIC_CLI_LOOKUP_VERSION,
10
+ HOMEPAGE_CLI_LOOKUP_VERSION,
8
11
  CLI_LOOKUP_VERSION,
9
12
  ]);
10
13
  export const MAX_CLI_LOOKUP_BYTES = 8 * 1024 * 1024;
@@ -43,6 +46,7 @@ export const CLI_LOOKUP_OPTIONAL_ROOT_KEYS = Object.freeze([
43
46
  'sslbl',
44
47
  'threatIntelligence',
45
48
  'collectionContext',
49
+ 'collectionReceipt',
46
50
  ]);
47
51
  export const CLI_LOOKUP_DIAGNOSTIC_REQUIRED_KEYS = Object.freeze([
48
52
  'rdap',
@@ -60,7 +64,7 @@ export const CLI_LOOKUP_COMPATIBILITY = defineSchemaCompatibility({
60
64
  writeSemantics: 'normalized_rewrite',
61
65
  byteBudget: MAX_CLI_LOOKUP_BYTES,
62
66
  owner: 'packages/contracts/cli-lookup.mts',
63
- note: 'Saved Lookup documents preserve version 1 domain, IP and ASN compatibility while version 2 can additionally retain bounded Deep homepage metadata and optional official registrar-standing context.',
67
+ note: 'Saved Lookup documents preserve versions 1 and 2; version 3 may retain a bounded collection receipt for the completed operation. Homepage metadata and registrar-standing context remain supported from version 2.',
64
68
  });
65
69
  export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
66
70
  id: 'cli-lookup',
@@ -87,11 +91,11 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
87
91
  {
88
92
  compatibilityId: CLI_LOOKUP_COMPATIBILITY.id,
89
93
  schema: CLI_LOOKUP_SCHEMA,
90
- version: CLI_LOOKUP_VERSION,
94
+ version: HOMEPAGE_CLI_LOOKUP_VERSION,
91
95
  role: 'document',
92
- lifecycle: 'current',
96
+ lifecycle: 'legacy',
93
97
  readable: true,
94
- emitted: true,
98
+ emitted: false,
95
99
  exactKeys: false,
96
100
  extensionPolicy: 'preserve_bounded',
97
101
  futureVersionBehaviour: 'reject',
@@ -100,8 +104,29 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
100
104
  byteBudget: MAX_CLI_LOOKUP_BYTES,
101
105
  fixtureIds: ['cli-lookup-v2', 'cli-lookup-asn-v2'],
102
106
  },
107
+ {
108
+ compatibilityId: CLI_LOOKUP_COMPATIBILITY.id,
109
+ schema: CLI_LOOKUP_SCHEMA,
110
+ version: CLI_LOOKUP_VERSION,
111
+ role: 'document', lifecycle: 'current', readable: true, emitted: true,
112
+ exactKeys: false, extensionPolicy: 'preserve_bounded', futureVersionBehaviour: 'reject',
113
+ migrationTarget: null, canonicalisation: null, byteBudget: MAX_CLI_LOOKUP_BYTES,
114
+ fixtureIds: ['cli-lookup-v3', 'cli-lookup-asn-v3'],
115
+ },
103
116
  ],
104
117
  fixtures: [
118
+ {
119
+ id: 'cli-lookup-v3', path: 'test/fixtures/cli-lookup-v3.json',
120
+ bytes: 7151, sha256: 'ee06201deb99ef1cdc5e79dc026a733fb14494593ee7ffdff3035b1fbc19d61e', contentDigestSha256: null,
121
+ schema: CLI_LOOKUP_SCHEMA, version: CLI_LOOKUP_VERSION, role: 'current',
122
+ expectation: 'accepted_exact', expectedOutputFixtureId: null, scope: 'repository',
123
+ },
124
+ {
125
+ id: 'cli-lookup-asn-v3', path: 'test/fixtures/cli-lookup-asn-v3.json',
126
+ bytes: 3506, sha256: 'c0a3dfe9d9c13d3f03079c6c76b33b353a6029b9a9893cbdb743b3f4d864c74e', contentDigestSha256: null,
127
+ schema: CLI_LOOKUP_SCHEMA, version: CLI_LOOKUP_VERSION, role: 'current',
128
+ expectation: 'accepted_exact', expectedOutputFixtureId: null, scope: 'repository',
129
+ },
105
130
  {
106
131
  id: 'cli-lookup-v1',
107
132
  path: 'test/fixtures/cli-lookup-v1.json',
@@ -122,8 +147,8 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
122
147
  sha256: 'd98356ede18ddd12fde8cf10b0cfa43c42f6c9a09f1951e0605e1ec912892778',
123
148
  contentDigestSha256: null,
124
149
  schema: CLI_LOOKUP_SCHEMA,
125
- version: CLI_LOOKUP_VERSION,
126
- role: 'current',
150
+ version: HOMEPAGE_CLI_LOOKUP_VERSION,
151
+ role: 'historical',
127
152
  expectation: 'accepted_exact',
128
153
  expectedOutputFixtureId: null,
129
154
  scope: 'repository',
@@ -148,8 +173,8 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
148
173
  sha256: 'a3ee74df02050d6efd935784734c8c4cfd5fcb9833393de68935f5040faf2856',
149
174
  contentDigestSha256: null,
150
175
  schema: CLI_LOOKUP_SCHEMA,
151
- version: CLI_LOOKUP_VERSION,
152
- role: 'current',
176
+ version: HOMEPAGE_CLI_LOOKUP_VERSION,
177
+ role: 'historical',
153
178
  expectation: 'accepted_exact',
154
179
  expectedOutputFixtureId: null,
155
180
  scope: 'repository',
@@ -158,26 +183,12 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
158
183
  metadata: {
159
184
  enforcement: 'declarative_only',
160
185
  shapes: [
161
- {
162
- id: 'cli-lookup.document.v1',
163
- schema: CLI_LOOKUP_SCHEMA,
164
- versions: [PUBLIC_CLI_LOOKUP_VERSION],
165
- objects: [
166
- { path: '$', requiredKeys: CLI_LOOKUP_REQUIRED_ROOT_KEYS, optionalKeys: CLI_LOOKUP_OPTIONAL_ROOT_KEYS, unknownKeys: 'preserve_bounded' },
167
- { path: '$.diagnostics', requiredKeys: CLI_LOOKUP_DIAGNOSTIC_REQUIRED_KEYS, optionalKeys: [], unknownKeys: 'preserve_bounded' },
168
- { path: '$.diagnostics.rdap', requiredKeys: ['status'], optionalKeys: [], unknownKeys: 'preserve_bounded' },
169
- { path: '$.diagnostics.whois', requiredKeys: ['status'], optionalKeys: [], unknownKeys: 'preserve_bounded' },
170
- ],
171
- fixedArrays: [],
172
- normalisation: 'preserve_bounded_document',
173
- target: null,
174
- },
175
- {
176
- id: 'cli-lookup.document.v2',
186
+ ...SUPPORTED_CLI_LOOKUP_VERSIONS.map(version => ({
187
+ id: `cli-lookup.document.v${version}`,
177
188
  schema: CLI_LOOKUP_SCHEMA,
178
- versions: [CLI_LOOKUP_VERSION],
189
+ versions: [version],
179
190
  objects: [
180
- { path: '$', requiredKeys: CLI_LOOKUP_REQUIRED_ROOT_KEYS, optionalKeys: CLI_LOOKUP_OPTIONAL_ROOT_KEYS, unknownKeys: 'preserve_bounded' },
191
+ { path: '$', requiredKeys: CLI_LOOKUP_REQUIRED_ROOT_KEYS, optionalKeys: CLI_LOOKUP_OPTIONAL_ROOT_KEYS.filter(key => version >= COLLECTION_RECEIPT_CLI_LOOKUP_VERSION || key !== 'collectionReceipt'), unknownKeys: 'preserve_bounded' },
181
192
  { path: '$.diagnostics', requiredKeys: CLI_LOOKUP_DIAGNOSTIC_REQUIRED_KEYS, optionalKeys: [], unknownKeys: 'preserve_bounded' },
182
193
  { path: '$.diagnostics.rdap', requiredKeys: ['status'], optionalKeys: [], unknownKeys: 'preserve_bounded' },
183
194
  { path: '$.diagnostics.whois', requiredKeys: ['status'], optionalKeys: [], unknownKeys: 'preserve_bounded' },
@@ -185,7 +196,7 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
185
196
  fixedArrays: [],
186
197
  normalisation: 'preserve_bounded_document',
187
198
  target: null,
188
- },
199
+ })),
189
200
  ],
190
201
  boundProfiles: [
191
202
  {
@@ -214,9 +225,9 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
214
225
  ],
215
226
  serialisationProfiles: [
216
227
  {
217
- id: 'cli-lookup.json.v1-v2',
228
+ id: 'cli-lookup.json.v1-v3',
218
229
  schema: CLI_LOOKUP_SCHEMA,
219
- versions: [PUBLIC_CLI_LOOKUP_VERSION, CLI_LOOKUP_VERSION],
230
+ versions: SUPPORTED_CLI_LOOKUP_VERSIONS,
220
231
  mediaType: 'application/json',
221
232
  encoding: 'utf-8',
222
233
  bom: false,
@@ -234,7 +245,7 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
234
245
  id: 'cli-lookup.sensitive-collection.v1',
235
246
  classification: 'analyst_authored_sensitive',
236
247
  projection: 'full_manifest',
237
- includedCategories: ['target-identifiers', 'query-and-mode', 'raw-rdap-publications', 'raw-whois-responses', 'published-contacts', 'source-endpoints', 'source-timings', 'normalised-evidence', 'registrar-standing-official-projection', 'collection-context'],
248
+ includedCategories: ['target-identifiers', 'query-and-mode', 'raw-rdap-publications', 'raw-whois-responses', 'published-contacts', 'source-endpoints', 'source-timings', 'normalised-evidence', 'registrar-standing-official-projection', 'collection-context', 'collection-receipt'],
238
249
  excludedCategories: ['credentials', 'cookies', 'sessions', 'authorisation-material'],
239
250
  notePolicy: 'not_applicable',
240
251
  retention: 'operator_controlled_output',
@@ -245,7 +256,7 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
245
256
  id: 'cli-lookup.sensitive-file.v1',
246
257
  classification: 'analyst_authored_sensitive',
247
258
  projection: 'full_manifest',
248
- includedCategories: ['target-identifiers', 'query-and-mode', 'raw-rdap-publications', 'raw-whois-responses', 'published-contacts', 'source-endpoints', 'source-timings', 'normalised-evidence', 'registrar-standing-official-projection', 'collection-context'],
259
+ includedCategories: ['target-identifiers', 'query-and-mode', 'raw-rdap-publications', 'raw-whois-responses', 'published-contacts', 'source-endpoints', 'source-timings', 'normalised-evidence', 'registrar-standing-official-projection', 'collection-context', 'collection-receipt'],
249
260
  excludedCategories: ['credentials', 'cookies', 'sessions', 'authorisation-material'],
250
261
  notePolicy: 'not_applicable',
251
262
  retention: 'deliberate_local_file',
@@ -257,7 +268,7 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
257
268
  classification: 'metadata_only',
258
269
  projection: 'metadata_only',
259
270
  includedCategories: ['schema-and-version', 'source-state-counts', 'bounded-timings', 'integrity-state'],
260
- excludedCategories: ['target-identifiers', 'query-and-mode', 'raw-rdap-publications', 'raw-whois-responses', 'published-contacts', 'source-endpoints', 'normalised-evidence', 'collection-context'],
271
+ excludedCategories: ['target-identifiers', 'query-and-mode', 'raw-rdap-publications', 'raw-whois-responses', 'published-contacts', 'source-endpoints', 'normalised-evidence', 'collection-context', 'collection-receipt'],
261
272
  notePolicy: 'not_applicable',
262
273
  retention: 'transient_report',
263
274
  network: 'none',
@@ -268,7 +279,7 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
268
279
  classification: 'metadata_only',
269
280
  projection: 'metadata_only',
270
281
  includedCategories: ['schema-and-version', 'source-state-counts', 'bounded-timings', 'truncation-and-rate-limit-counts', 'aggregate-lookup-mode-counts'],
271
- excludedCategories: ['target-identifiers', 'query-values', 'per-document-linkage', 'raw-rdap-publications', 'raw-whois-responses', 'published-contacts', 'source-endpoints', 'normalised-evidence', 'collection-context'],
282
+ excludedCategories: ['target-identifiers', 'query-values', 'per-document-linkage', 'raw-rdap-publications', 'raw-whois-responses', 'published-contacts', 'source-endpoints', 'normalised-evidence', 'collection-context', 'collection-receipt'],
272
283
  notePolicy: 'not_applicable',
273
284
  retention: 'transient_report',
274
285
  network: 'none',
@@ -292,10 +303,10 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
292
303
  operation: 'collect-and-serialise-fast-or-deep',
293
304
  acceptedContracts: [],
294
305
  emittedContract: { schema: CLI_LOOKUP_SCHEMA, version: CLI_LOOKUP_VERSION },
295
- shapeIds: ['cli-lookup.document.v2'],
306
+ shapeIds: [`cli-lookup.document.v${CLI_LOOKUP_VERSION}`],
296
307
  boundProfileIds: ['cli-lookup.document-bounds.v1'],
297
308
  hookIds: ['cli-lookup.cli.build', 'cli-lookup.cli.serialise'],
298
- serialisationProfileId: 'cli-lookup.json.v1-v2',
309
+ serialisationProfileId: 'cli-lookup.json.v1-v3',
299
310
  privacyProfileId: 'cli-lookup.sensitive-collection.v1',
300
311
  expiryPolicyId: 'cli-lookup.expiry.not-applicable.v1',
301
312
  requestMode: 'explicit_bounded_passive_fast_or_deep',
@@ -307,9 +318,9 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
307
318
  id: 'cli-lookup.cli-read-all',
308
319
  plane: 'cli',
309
320
  operation: 'read-all-query-types',
310
- acceptedContracts: [{ schema: CLI_LOOKUP_SCHEMA, versions: [PUBLIC_CLI_LOOKUP_VERSION, CLI_LOOKUP_VERSION], mode: 'direct' }],
321
+ acceptedContracts: [{ schema: CLI_LOOKUP_SCHEMA, versions: SUPPORTED_CLI_LOOKUP_VERSIONS, mode: 'direct' }],
311
322
  emittedContract: null,
312
- shapeIds: ['cli-lookup.document.v1', 'cli-lookup.document.v2'],
323
+ shapeIds: SUPPORTED_CLI_LOOKUP_VERSIONS.map(version => `cli-lookup.document.v${version}`),
313
324
  boundProfileIds: ['cli-lookup.document-bounds.v1'],
314
325
  hookIds: ['cli-lookup.cli.parse-all'],
315
326
  serialisationProfileId: null,
@@ -324,9 +335,9 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
324
335
  id: 'cli-lookup.cli-read-domain',
325
336
  plane: 'cli',
326
337
  operation: 'read-domain-only',
327
- acceptedContracts: [{ schema: CLI_LOOKUP_SCHEMA, versions: [PUBLIC_CLI_LOOKUP_VERSION, CLI_LOOKUP_VERSION], mode: 'direct', discriminator: { path: '$.type', values: ['domain'] } }],
338
+ acceptedContracts: [{ schema: CLI_LOOKUP_SCHEMA, versions: SUPPORTED_CLI_LOOKUP_VERSIONS, mode: 'direct', discriminator: { path: '$.type', values: ['domain'] } }],
328
339
  emittedContract: null,
329
- shapeIds: ['cli-lookup.document.v1', 'cli-lookup.document.v2'],
340
+ shapeIds: SUPPORTED_CLI_LOOKUP_VERSIONS.map(version => `cli-lookup.document.v${version}`),
330
341
  boundProfileIds: ['cli-lookup.document-bounds.v1'],
331
342
  hookIds: ['cli-lookup.cli.parse-domain'],
332
343
  serialisationProfileId: null,
@@ -341,9 +352,9 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
341
352
  id: 'cli-lookup.cli-source-report',
342
353
  plane: 'cli',
343
354
  operation: 'build-source-reliability-report',
344
- acceptedContracts: [{ schema: CLI_LOOKUP_SCHEMA, versions: [PUBLIC_CLI_LOOKUP_VERSION, CLI_LOOKUP_VERSION], mode: 'direct' }],
355
+ acceptedContracts: [{ schema: CLI_LOOKUP_SCHEMA, versions: SUPPORTED_CLI_LOOKUP_VERSIONS, mode: 'direct' }],
345
356
  emittedContract: null,
346
- shapeIds: ['cli-lookup.document.v1', 'cli-lookup.document.v2'],
357
+ shapeIds: SUPPORTED_CLI_LOOKUP_VERSIONS.map(version => `cli-lookup.document.v${version}`),
347
358
  boundProfileIds: ['cli-lookup.document-bounds.v1'],
348
359
  hookIds: ['cli-lookup.cli.normalise', 'cli-lookup.cli.source-report'],
349
360
  serialisationProfileId: null,
@@ -358,9 +369,9 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
358
369
  id: 'cli-lookup.cli-offline-verify',
359
370
  plane: 'cli',
360
371
  operation: 'verify-structure',
361
- acceptedContracts: [{ schema: CLI_LOOKUP_SCHEMA, versions: [PUBLIC_CLI_LOOKUP_VERSION, CLI_LOOKUP_VERSION], mode: 'direct' }],
372
+ acceptedContracts: [{ schema: CLI_LOOKUP_SCHEMA, versions: SUPPORTED_CLI_LOOKUP_VERSIONS, mode: 'direct' }],
362
373
  emittedContract: null,
363
- shapeIds: ['cli-lookup.document.v1', 'cli-lookup.document.v2'],
374
+ shapeIds: SUPPORTED_CLI_LOOKUP_VERSIONS.map(version => `cli-lookup.document.v${version}`),
364
375
  boundProfileIds: ['cli-lookup.document-bounds.v1'],
365
376
  hookIds: ['cli-lookup.cli.parse-all', 'cli-lookup.cli.offline-verify'],
366
377
  serialisationProfileId: null,
@@ -375,9 +386,9 @@ export const CLI_LOOKUP_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
375
386
  id: 'cli-lookup.cli-sharing-review',
376
387
  plane: 'cli',
377
388
  operation: 'build-redacted-sharing-review',
378
- acceptedContracts: [{ schema: CLI_LOOKUP_SCHEMA, versions: [PUBLIC_CLI_LOOKUP_VERSION, CLI_LOOKUP_VERSION], mode: 'direct' }],
389
+ acceptedContracts: [{ schema: CLI_LOOKUP_SCHEMA, versions: SUPPORTED_CLI_LOOKUP_VERSIONS, mode: 'direct' }],
379
390
  emittedContract: null,
380
- shapeIds: ['cli-lookup.document.v1', 'cli-lookup.document.v2'],
391
+ shapeIds: SUPPORTED_CLI_LOOKUP_VERSIONS.map(version => `cli-lookup.document.v${version}`),
381
392
  boundProfileIds: ['cli-lookup.document-bounds.v1'],
382
393
  hookIds: ['cli-lookup.cli.parse-all', 'cli-lookup.cli.sharing-review'],
383
394
  serialisationProfileId: null,
@@ -1,4 +1,5 @@
1
1
  import { defineSchemaCompatibility } from "./schema-compatibility.mjs";
2
+ import { LEGACY_REGISTRY_INSIGHTS_VERSION, REGISTRY_INSIGHTS_VERSION } from "./registry-insights.mjs";
2
3
  export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
3
4
  export const V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION = 26;
4
5
  export const PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION = 27;
@@ -6,14 +7,23 @@ export const REGISTRAR_STANDING_LOOKUP_EVIDENCE_SCHEMA_VERSION = 28;
6
7
  export const LATEST_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION = REGISTRAR_STANDING_LOOKUP_EVIDENCE_SCHEMA_VERSION;
7
8
  export const HOSTNAME_SCOPED_LOOKUP_EVIDENCE_SCHEMA_VERSION = 29;
8
9
  export const PRIVACY_MINIMIZED_LOOKUP_EVIDENCE_SCHEMA_VERSION = PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION;
9
- export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 30;
10
+ export const FORM_ATTRIBUTED_LOOKUP_EVIDENCE_SCHEMA_VERSION = 30;
11
+ export const AUTHORITY_AWARE_LOOKUP_EVIDENCE_SCHEMA_VERSION = 31;
12
+ export const LOOKUP_EVIDENCE_SCHEMA_VERSION = AUTHORITY_AWARE_LOOKUP_EVIDENCE_SCHEMA_VERSION;
10
13
  export const SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS = Object.freeze([
11
14
  V1_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION,
12
15
  PUBLISHED_V2_LOOKUP_EVIDENCE_SCHEMA_VERSION,
13
16
  LATEST_PUBLIC_LOOKUP_EVIDENCE_SCHEMA_VERSION,
14
17
  HOSTNAME_SCOPED_LOOKUP_EVIDENCE_SCHEMA_VERSION,
18
+ FORM_ATTRIBUTED_LOOKUP_EVIDENCE_SCHEMA_VERSION,
15
19
  LOOKUP_EVIDENCE_SCHEMA_VERSION,
16
20
  ]);
21
+ export function lookupEvidenceRegistryInsightsVersion(schemaVersion) {
22
+ if (!SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS.includes(schemaVersion))
23
+ throw new TypeError('Unsupported Lookup evidence version.');
24
+ return schemaVersion >= AUTHORITY_AWARE_LOOKUP_EVIDENCE_SCHEMA_VERSION
25
+ ? REGISTRY_INSIGHTS_VERSION : LEGACY_REGISTRY_INSIGHTS_VERSION;
26
+ }
17
27
  export const LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES = 5 * 1024 * 1024;
18
28
  export const LOOKUP_EVIDENCE_PORTABLE_MAX_ENTRIES = 20_000;
19
29
  export const LOOKUP_EVIDENCE_PORTABLE_MAX_DEPTH = 24;
@@ -32,5 +42,5 @@ export const LOOKUP_EVIDENCE_COMPATIBILITY = defineSchemaCompatibility({
32
42
  writeSemantics: 'read_only',
33
43
  byteBudget: LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES,
34
44
  owner: 'packages/contracts/lookup-evidence.mts',
35
- note: 'Published versions 26–29 retain their original collection scope. Version 30 adds attributed password-form destinations; older page-wide flags are not form ownership evidence. Registration authority remains separate.',
45
+ note: 'Versions 26–30 retain their registry insight derivation. Version 30 adds attributed form destinations. Version 31 retains WHOIS registration classification; collection completion is not positive registration evidence.',
36
46
  });
@@ -0,0 +1,6 @@
1
+ /** Version 1 is retained for exact historical evidence verification. */
2
+ export const LEGACY_REGISTRY_INSIGHTS_VERSION = 1;
3
+ export const REGISTRY_INSIGHTS_VERSION = 2;
4
+ export function isSupportedRegistryInsightsVersion(value) {
5
+ return value === LEGACY_REGISTRY_INSIGHTS_VERSION || value === REGISTRY_INSIGHTS_VERSION;
6
+ }
@@ -0,0 +1,80 @@
1
+ import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "./observation.mjs";
3
+ export const MAX_DMARC_DISCOVERY_OWNERS = 8;
4
+ function closedRecord(value, keys) {
5
+ if (!value || typeof value !== 'object' || Array.isArray(value)
6
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(value))
7
+ || Reflect.ownKeys(value).length !== keys.length)
8
+ throw new TypeError('DMARC policy context has an invalid structure.');
9
+ const result = {};
10
+ for (const key of keys) {
11
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
12
+ if (!descriptor?.enumerable || !('value' in descriptor))
13
+ throw new TypeError('DMARC policy context requires ordinary data fields.');
14
+ result[key] = descriptor.value;
15
+ }
16
+ return result;
17
+ }
18
+ function owner(value) {
19
+ return typeof value === 'string' && value.length <= 253
20
+ && isValidAsciiHostname(value, { requireDot: false, requireLowercase: true });
21
+ }
22
+ function time(value) {
23
+ if (value === null)
24
+ return null;
25
+ const normalized = typeof value === 'string' && value.length <= 64 ? normalizeExplicitIsoTimestamp(value) : null;
26
+ if (normalized === null)
27
+ throw new TypeError('DMARC policy context has an invalid observation time.');
28
+ return normalized;
29
+ }
30
+ /** Current opted-in review only; absent historical context is never reconstructed. */
31
+ export function normalizeDmarcPolicyContext(value, expectedDomain) {
32
+ if (value === undefined)
33
+ return undefined;
34
+ const input = closedRecord(value, ['version', 'source', 'relation', 'complete', 'owner', 'observedAt', 'policy', 'subdomainPolicy', 'nonexistentPolicy', 'psd', 'testMode', 'discovery']);
35
+ if (input.version !== 1 || input.source !== 'recursive_dns_txt' || typeof input.complete !== 'boolean'
36
+ || typeof input.relation !== 'string' || !['exact', 'inherited', 'undetermined'].includes(input.relation)
37
+ || !Array.isArray(input.discovery) || input.discovery.length < 1 || input.discovery.length > MAX_DMARC_DISCOVERY_OWNERS) {
38
+ throw new TypeError('DMARC policy context has an unsupported version or discovery state.');
39
+ }
40
+ const discovery = [];
41
+ for (let index = 0; index < input.discovery.length; index++) {
42
+ const entry = Object.getOwnPropertyDescriptor(input.discovery, String(index));
43
+ if (!entry?.enumerable || !('value' in entry))
44
+ throw new TypeError('DMARC discovery requires ordinary entries.');
45
+ const row = closedRecord(entry.value, ['owner', 'state', 'observedAt']);
46
+ if (!owner(row.owner) || typeof row.state !== 'string' || !['observed', 'not_found', 'invalid', 'unavailable'].includes(row.state)) {
47
+ throw new TypeError('DMARC discovery has an invalid owner or state.');
48
+ }
49
+ discovery.push(Object.freeze({ owner: row.owner, state: row.state, observedAt: time(row.observedAt) }));
50
+ }
51
+ if (new Set(discovery.map(row => row.owner)).size !== discovery.length
52
+ || (expectedDomain !== undefined && discovery[0].owner !== expectedDomain)
53
+ || discovery.some((row, index) => index > 0 && !discovery[index - 1].owner.endsWith(`.${row.owner}`))
54
+ || (input.complete && discovery.some(row => row.state === 'unavailable'))) {
55
+ throw new TypeError('DMARC discovery does not match one bounded owner walk.');
56
+ }
57
+ const observedAt = time(input.observedAt);
58
+ if (input.relation === 'undetermined') {
59
+ if ([input.owner, observedAt, input.policy, input.subdomainPolicy, input.nonexistentPolicy, input.psd, input.testMode].some(field => field !== null)) {
60
+ throw new TypeError('Undetermined DMARC policy must not declare a selected policy.');
61
+ }
62
+ }
63
+ else {
64
+ const selected = discovery.find(row => row.owner === input.owner);
65
+ if (!input.complete || !owner(input.owner) || !selected || selected.state !== 'observed' || selected.observedAt !== observedAt
66
+ || (input.relation === 'exact') !== (input.owner === discovery[0].owner)
67
+ || [input.policy, input.subdomainPolicy, input.nonexistentPolicy].some(field => typeof field !== 'string' || !['none', 'quarantine', 'reject'].includes(field))
68
+ || typeof input.psd !== 'string' || !['u', 'n', 'y'].includes(input.psd) || typeof input.testMode !== 'boolean') {
69
+ throw new TypeError('DMARC policy does not match a complete selected observation.');
70
+ }
71
+ }
72
+ return Object.freeze({
73
+ version: 1, source: 'recursive_dns_txt', relation: input.relation,
74
+ complete: input.complete, owner: input.owner, observedAt,
75
+ policy: input.policy, subdomainPolicy: input.subdomainPolicy,
76
+ nonexistentPolicy: input.nonexistentPolicy,
77
+ psd: input.psd, testMode: input.testMode,
78
+ discovery: Object.freeze(discovery),
79
+ });
80
+ }
@@ -0,0 +1,140 @@
1
+ // A bounded account of one completed Lookup operation. This records collector
2
+ // execution, not proof of network delivery, independent verification or history.
3
+ export const LOOKUP_SOURCE_IDS = Object.freeze([
4
+ 'rdap', 'whois', 'domain_evidence', 'reverse_dns', 'registrar_rdap',
5
+ 'network_context', 'security_txt', 'external_intelligence',
6
+ 'malware_host_intelligence', 'malware_ioc_intelligence',
7
+ ]);
8
+ export const LOOKUP_SOURCE_STATES = Object.freeze([
9
+ 'success', 'partial', 'not_found', 'skipped', 'error', 'unsupported',
10
+ 'not_applicable', 'unavailable', 'rate_limited', 'complete', 'disabled', 'stale',
11
+ ]);
12
+ export const LOOKUP_COLLECTION_RECEIPT_VERSION = 1;
13
+ export const LOOKUP_CHOICE_KEYS = Object.freeze([
14
+ 'securityTxt', 'externalIntelligence', 'malwareHostIntelligence', 'malwareIocIntelligence',
15
+ ]);
16
+ export const LOOKUP_CHOICE_SOURCES = Object.freeze({
17
+ securityTxt: 'security_txt', externalIntelligence: 'external_intelligence',
18
+ malwareHostIntelligence: 'malware_host_intelligence', malwareIocIntelligence: 'malware_ioc_intelligence',
19
+ });
20
+ const ADMISSIONS = ['admitted', 'disabled', 'unsupported', 'not_applicable', 'prerequisite_unavailable', 'unknown'];
21
+ function fields(value, keys) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value))
23
+ throw new TypeError('Collection receipt must contain ordinary objects.');
24
+ const prototype = Object.getPrototypeOf(value);
25
+ if (prototype !== Object.prototype && prototype !== null)
26
+ throw new TypeError('Collection receipt must contain ordinary objects.');
27
+ const own = Reflect.ownKeys(value);
28
+ if (own.length !== keys.length || own.some(key => typeof key !== 'string' || !keys.includes(key))) {
29
+ throw new TypeError('Collection receipt contains an unsupported field set.');
30
+ }
31
+ const output = {};
32
+ for (const key of keys) {
33
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
34
+ if (!descriptor?.enumerable || !('value' in descriptor))
35
+ throw new TypeError('Collection receipt contains an invalid field.');
36
+ output[key] = descriptor.value;
37
+ }
38
+ return output;
39
+ }
40
+ function oneOf(value, options) {
41
+ if (typeof value !== 'string' || !options.includes(value))
42
+ throw new TypeError('Collection receipt contains an invalid state.');
43
+ return value;
44
+ }
45
+ function quality(value) {
46
+ if (value !== null && typeof value !== 'boolean')
47
+ throw new TypeError('Collection receipt quality must be explicit or unknown.');
48
+ return value;
49
+ }
50
+ export function lookupCollectionChoices(input) {
51
+ return Object.freeze({
52
+ securityTxt: input.securityTxt === true,
53
+ externalIntelligence: input.externalIntelligence === true,
54
+ malwareHostIntelligence: input.malwareHostIntelligence === true,
55
+ malwareIocIntelligence: input.malwareIocIntelligence === true,
56
+ });
57
+ }
58
+ /** Absence is historical unknown, never reconstructed as a negative choice. */
59
+ export function parseLookupCollectionReceipt(raw) {
60
+ if (raw === undefined)
61
+ return null;
62
+ const value = fields(raw, ['version', 'requestedDepth', 'targetClass', 'webSelection', 'originalChoices', 'sources']);
63
+ if (value.version !== LOOKUP_COLLECTION_RECEIPT_VERSION)
64
+ throw new TypeError('Collection receipt version is unsupported.');
65
+ const choices = fields(value.originalChoices, LOOKUP_CHOICE_KEYS);
66
+ if (Object.values(choices).some(choice => typeof choice !== 'boolean'))
67
+ throw new TypeError('Collection receipt choices must be explicit.');
68
+ const requestedDepth = oneOf(value.requestedDepth, ['fast', 'deep']);
69
+ const targetClass = oneOf(value.targetClass, ['domain', 'ipv4', 'ipv6', 'asn']);
70
+ const webSelection = oneOf(value.webSelection, ['hostname', 'selected_url', 'not_applicable']);
71
+ if ((targetClass !== 'domain' && webSelection !== 'not_applicable') || (webSelection === 'selected_url' && requestedDepth !== 'deep')) {
72
+ throw new TypeError('Collection receipt target and web selection disagree.');
73
+ }
74
+ if (!Array.isArray(value.sources) || Object.getPrototypeOf(value.sources) !== Array.prototype
75
+ || value.sources.length !== LOOKUP_SOURCE_IDS.length
76
+ || Reflect.ownKeys(value.sources).length !== LOOKUP_SOURCE_IDS.length + 1)
77
+ throw new TypeError('Collection receipt source inventory is invalid.');
78
+ const bySource = new Map();
79
+ for (let index = 0; index < LOOKUP_SOURCE_IDS.length; index++) {
80
+ const descriptor = Object.getOwnPropertyDescriptor(value.sources, String(index));
81
+ if (!descriptor?.enumerable || !('value' in descriptor))
82
+ throw new TypeError('Collection receipt source entry is invalid.');
83
+ const row = fields(descriptor.value, ['source', 'intent', 'admission', 'attempt', 'settlement', 'complete', 'truncated']);
84
+ const source = oneOf(row.source, LOOKUP_SOURCE_IDS);
85
+ if (bySource.has(source))
86
+ throw new TypeError('Collection receipt source is duplicated.');
87
+ const attempt = oneOf(row.attempt, ['started', 'not_started', 'unknown']);
88
+ const admission = oneOf(row.admission, ADMISSIONS);
89
+ const complete = quality(row.complete), truncated = quality(row.truncated);
90
+ const intent = oneOf(row.intent, ['requested', 'not_requested']);
91
+ const settlement = oneOf(row.settlement, [...LOOKUP_SOURCE_STATES, 'unknown']);
92
+ if (attempt === 'started' && admission !== 'admitted'
93
+ || attempt === 'started' && intent !== 'requested'
94
+ || attempt !== 'started' && (complete !== null || truncated !== null)
95
+ || attempt === 'not_started' && settlement !== 'unknown'
96
+ || complete === true && !['success', 'complete', 'not_found'].includes(settlement)
97
+ || complete === true && truncated === true)
98
+ throw new TypeError('Collection receipt execution and quality disagree.');
99
+ bySource.set(source, Object.freeze({
100
+ source, intent, admission, attempt, settlement, complete, truncated,
101
+ }));
102
+ }
103
+ for (const choice of LOOKUP_CHOICE_KEYS) {
104
+ if ((bySource.get(LOOKUP_CHOICE_SOURCES[choice]).intent === 'requested') !== choices[choice]) {
105
+ throw new TypeError('Collection receipt choice and source intent disagree.');
106
+ }
107
+ }
108
+ return Object.freeze({ version: LOOKUP_COLLECTION_RECEIPT_VERSION, requestedDepth, targetClass, webSelection,
109
+ originalChoices: lookupCollectionChoices(choices),
110
+ sources: Object.freeze(LOOKUP_SOURCE_IDS.map(source => bySource.get(source))),
111
+ });
112
+ }
113
+ /** Bind a validated execution account to this submission's pre-filter choices.
114
+ * A browser may omit an ineligible optional request; the server cannot know
115
+ * which unchecked/disabled control led to that omission. Do not invent why. */
116
+ export function bindLookupCollectionChoices(raw, originalChoices) {
117
+ const receipt = parseLookupCollectionReceipt(raw);
118
+ if (!receipt)
119
+ return null;
120
+ const choices = fields(originalChoices, LOOKUP_CHOICE_KEYS);
121
+ if (Object.values(choices).some(choice => typeof choice !== 'boolean'))
122
+ throw new TypeError('Collection receipt choices must be explicit.');
123
+ const sources = receipt.sources.map(row => {
124
+ const choice = LOOKUP_CHOICE_KEYS.find(key => LOOKUP_CHOICE_SOURCES[key] === row.source);
125
+ if (!choice || choices[choice] === receipt.originalChoices[choice])
126
+ return { ...row };
127
+ if (receipt.originalChoices[choice] || choices[choice] !== true || row.attempt !== 'not_started') {
128
+ throw new TypeError('Collection receipt does not match the submitted choices.');
129
+ }
130
+ return { ...row, intent: 'requested', admission: 'unknown' };
131
+ });
132
+ const bound = { ...receipt, originalChoices: lookupCollectionChoices(choices), sources };
133
+ parseLookupCollectionReceipt(bound);
134
+ return bound;
135
+ }
136
+ /** Stable, payload-free material comparison. Parsing also removes key order as a difference. */
137
+ export function lookupCollectionReceiptIdentity(raw) {
138
+ const receipt = parseLookupCollectionReceipt(raw);
139
+ return receipt ? JSON.stringify(receipt) : null;
140
+ }
@@ -0,0 +1,74 @@
1
+ import { LOOKUP_CHOICE_KEYS, LOOKUP_CHOICE_SOURCES, parseLookupCollectionReceipt } from "./lookup-collection-receipt.mjs";
2
+ export const LOOKUP_SOURCE_LABELS = Object.freeze({
3
+ rdap: 'Registry RDAP', whois: 'WHOIS chain', domain_evidence: 'Domain evidence',
4
+ reverse_dns: 'Reverse DNS', registrar_rdap: 'Registrar RDAP', network_context: 'Network context',
5
+ security_txt: 'security.txt', external_intelligence: 'Archived web intelligence',
6
+ malware_host_intelligence: 'Malware host intelligence', malware_ioc_intelligence: 'Malware infrastructure intelligence',
7
+ });
8
+ const ADMISSION_LABELS = Object.freeze({
9
+ admitted: 'Admitted', disabled: 'Disabled by policy', unsupported: 'Unsupported', not_applicable: 'Not applicable',
10
+ prerequisite_unavailable: 'Required earlier evidence unavailable', unknown: 'Admission not recorded',
11
+ });
12
+ const ATTEMPT_LABELS = Object.freeze({ started: 'Collector started', not_started: 'Collector not started', unknown: 'Collector start unknown' });
13
+ /** Inert guidance from recorded states, never an admission or retry decision. */
14
+ export function lookupSourceStateExplanation(state, source, receipt) {
15
+ if (receipt && receipt.source === source && receipt.attempt === 'not_started') {
16
+ if (receipt.intent === 'not_requested')
17
+ return 'Not requested in this Lookup. Select it explicitly in a new Lookup if needed.';
18
+ if (receipt.admission === 'disabled')
19
+ return 'Collection was disabled by policy. Retrying does not change that policy.';
20
+ if (receipt.admission === 'prerequisite_unavailable')
21
+ return 'Required earlier evidence was unavailable, so this collector did not start. Review that source first.';
22
+ if (receipt.admission === 'not_applicable')
23
+ return 'This collector did not apply to the submitted target or mode.';
24
+ if (receipt.admission === 'unsupported')
25
+ return 'Collection was not supported for this target; retrying unchanged inputs will not add evidence.';
26
+ }
27
+ switch (state) {
28
+ case 'success':
29
+ case 'complete':
30
+ return 'Collection completed within this source’s scope. Review its observations and time below.';
31
+ case 'not_found':
32
+ return 'This source returned no matching record. Its authority and search scope determine what that means.';
33
+ case 'partial':
34
+ case 'inconclusive':
35
+ return 'Evidence is incomplete or inconclusive. Missing fields remain unknown; a later retry may help if source conditions change.';
36
+ case 'error':
37
+ case 'unavailable':
38
+ return 'Usable evidence was not obtained. This is not an absence finding; retry may help after the reported problem changes.';
39
+ case 'rate_limited':
40
+ return 'The source reported a rate limit. Wait for its permitted retry window; no reset time is assumed.';
41
+ case 'unsupported':
42
+ return source === 'rdap' || source === 'whois'
43
+ ? 'No applicable service was recorded by registry policy or discovery. Retrying unchanged discovery may not add evidence.'
44
+ : 'This source does not support the target or request. Use an applicable source rather than repeating the same request.';
45
+ case 'skipped':
46
+ return 'This source was not queried under the selected mode or access policy. Review its source details before requesting it again.';
47
+ case 'disabled':
48
+ return 'Collection was disabled by policy. Retrying does not change that policy.';
49
+ case 'not_applicable':
50
+ return 'This source does not apply to the submitted target or mode.';
51
+ case 'stale':
52
+ return 'Retained evidence is out of date for this review. A deliberate refresh may provide a newer observation.';
53
+ default:
54
+ return 'The source outcome was not retained. Its request history and retry usefulness are unknown.';
55
+ }
56
+ }
57
+ /** One presentation of the closed receipt vocabulary for browser and terminal. */
58
+ export function lookupCollectionReceiptReview(raw) {
59
+ const receipt = parseLookupCollectionReceipt(raw);
60
+ if (!receipt)
61
+ return null;
62
+ return {
63
+ receipt,
64
+ selection: `${receipt.requestedDepth === 'deep' ? 'Deep' : 'Fast'} Lookup · ${receipt.webSelection === 'selected_url'
65
+ ? 'selected URL' : receipt.webSelection === 'hostname' ? 'hostname' : 'no web target'}`,
66
+ choices: LOOKUP_CHOICE_KEYS.map(choice => ({ label: LOOKUP_SOURCE_LABELS[LOOKUP_CHOICE_SOURCES[choice]], selected: receipt.originalChoices[choice] })),
67
+ sources: receipt.sources.map(row => ({
68
+ id: row.source, label: LOOKUP_SOURCE_LABELS[row.source],
69
+ summary: `${row.intent === 'requested' ? 'Requested' : 'Not requested'} · ${ADMISSION_LABELS[row.admission]} · ${ATTEMPT_LABELS[row.attempt]}`,
70
+ outcome: `Source result: ${row.settlement.replaceAll('_', ' ')} · ${row.complete === null ? 'completeness unknown' : row.complete ? 'complete' : 'not complete'} · ${row.truncated === null ? 'truncation unknown' : row.truncated ? 'truncated' : 'not truncated'}`,
71
+ explanation: lookupSourceStateExplanation(row.settlement, row.source, row),
72
+ })),
73
+ };
74
+ }