@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,8 +1,8 @@
1
1
  import { defineSchemaCompatibility } from "./schema-compatibility.mjs";
2
2
  import { defineSchemaLifecycleFamily } from "./schema-lifecycle.mjs";
3
3
  export const RISK_CALIBRATION_DATASET_SCHEMA = 'whoisleuth.risk-calibration-dataset';
4
- export const RISK_CALIBRATION_DATASET_VERSION = 2;
5
- export const SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS = Object.freeze([RISK_CALIBRATION_DATASET_VERSION]);
4
+ export const RISK_CALIBRATION_DATASET_VERSION = 3;
5
+ export const SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS = Object.freeze([2, RISK_CALIBRATION_DATASET_VERSION]);
6
6
  export const RISK_CALIBRATION_REPORT_SCHEMA = 'whoisleuth.cli.risk-calibration';
7
7
  export const RISK_CALIBRATION_REPORT_VERSION = 3;
8
8
  export const SUPPORTED_RISK_CALIBRATION_REPORT_VERSIONS = Object.freeze([RISK_CALIBRATION_REPORT_VERSION]);
@@ -82,7 +82,7 @@ export const RISK_CALIBRATION_EVIDENCE_OPTIONAL_KEYS = Object.freeze([
82
82
  'activityStatus', 'mutationTypes', 'domainAgeDays', 'faviconMatch',
83
83
  'faviconNearMatch', 'reusesOfficialAssets', 'hasPasswordField', 'hasMx',
84
84
  'hasSpf', 'hasDmarc', 'privacyProtected', 'phishingLanguageMatch',
85
- 'hasExternalFormAction', 'idnReferenceMatch', 'pageBaselineMatch',
85
+ 'hasExternalFormAction', 'hasExternalPasswordForm', 'idnReferenceMatch', 'pageBaselineMatch',
86
86
  'hasActiveBrandProfile', 'scanDepth', 'observedAt', 'threatIntelligence',
87
87
  ]);
88
88
  export const RISK_CALIBRATION_THREAT_INTELLIGENCE_KEYS = Object.freeze([
@@ -179,7 +179,7 @@ export const RISK_CALIBRATION_DATASET_COMPATIBILITY = defineSchemaCompatibility(
179
179
  writeSemantics: 'read_only',
180
180
  byteBudget: MAX_RISK_CALIBRATION_INPUT_BYTES,
181
181
  owner: 'packages/contracts/risk-calibration.mts',
182
- note: 'Offline labelled fixture input; version 2 adds bounded review context without live collection.',
182
+ note: 'Offline labelled fixture input. Version 2 retains bounded review context; version 3 adds explicit form attribution. Historical records remain readable without manufacturing missing associations.',
183
183
  });
184
184
  export const RISK_CALIBRATION_REPORT_COMPATIBILITY = defineSchemaCompatibility({
185
185
  id: 'cli.risk-calibration-report',
@@ -292,22 +292,22 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
292
292
  RISK_CALIBRATION_REPORT_COMPATIBILITY,
293
293
  ],
294
294
  contracts: [
295
- {
295
+ ...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.map((version) => ({
296
296
  compatibilityId: RISK_CALIBRATION_DATASET_COMPATIBILITY.id,
297
297
  schema: RISK_CALIBRATION_DATASET_SCHEMA,
298
- version: RISK_CALIBRATION_DATASET_VERSION,
298
+ version,
299
299
  role: 'document',
300
- lifecycle: 'current',
300
+ lifecycle: version === RISK_CALIBRATION_DATASET_VERSION ? 'current' : 'legacy',
301
301
  readable: true,
302
- emitted: true,
302
+ emitted: version === RISK_CALIBRATION_DATASET_VERSION,
303
303
  exactKeys: false,
304
304
  extensionPolicy: 'discard_bounded',
305
305
  futureVersionBehaviour: 'reject',
306
306
  migrationTarget: null,
307
307
  canonicalisation: null,
308
308
  byteBudget: MAX_RISK_CALIBRATION_INPUT_BYTES,
309
- fixtureIds: ['risk-calibration-dataset-v2'],
310
- },
309
+ fixtureIds: [`risk-calibration-dataset-v${version}`],
310
+ })),
311
311
  {
312
312
  compatibilityId: RISK_CALIBRATION_REPORT_COMPATIBILITY.id,
313
313
  schema: RISK_CALIBRATION_REPORT_SCHEMA,
@@ -329,6 +329,20 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
329
329
  },
330
330
  ],
331
331
  fixtures: [
332
+ {
333
+ id: 'risk-calibration-dataset-v3',
334
+ path: 'test/fixtures/risk-calibration-dataset-v3.json',
335
+ bytes: 1_096,
336
+ sha256: 'a8e41c1325094aec5ef4731fdf9f487a20999729f6cc704e400c3d83db2a0753',
337
+ contentDigestSha256: null,
338
+ schema: RISK_CALIBRATION_DATASET_SCHEMA,
339
+ version: 3,
340
+ role: 'current',
341
+ expectation: 'accepted_exact',
342
+ expectedOutputFixtureId: null,
343
+ scope: 'repository',
344
+ shapeId: 'risk-calibration.dataset.v3',
345
+ },
332
346
  {
333
347
  id: 'risk-calibration-dataset-v2',
334
348
  path: 'test/fixtures/risk-calibration-dataset-v2.json',
@@ -336,8 +350,8 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
336
350
  sha256: 'bd650fe84923c61658d451c25d928bd5ff8e54ff585f4f059e6798c681f9a401',
337
351
  contentDigestSha256: null,
338
352
  schema: RISK_CALIBRATION_DATASET_SCHEMA,
339
- version: RISK_CALIBRATION_DATASET_VERSION,
340
- role: 'current',
353
+ version: 2,
354
+ role: 'historical',
341
355
  expectation: 'accepted_exact',
342
356
  expectedOutputFixtureId: null,
343
357
  scope: 'repository',
@@ -375,11 +389,11 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
375
389
  metadata: {
376
390
  enforcement: 'declarative_only',
377
391
  shapes: [
378
- ...[RISK_CALIBRATION_DATASET_VERSION].map((version) => ({
392
+ ...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.map((version) => ({
379
393
  id: `risk-calibration.dataset.v${version}`,
380
394
  schema: RISK_CALIBRATION_DATASET_SCHEMA,
381
395
  versions: [version],
382
- objects: DATASET_SHAPE_OBJECTS,
396
+ objects: version === 2 ? DATASET_SHAPE_OBJECTS.map(object => ({ ...object, optionalKeys: object.optionalKeys.filter(key => key !== 'hasExternalPasswordForm') })) : DATASET_SHAPE_OBJECTS,
383
397
  fixedArrays: [],
384
398
  normalisation: 'project_known_fields',
385
399
  target: null,
@@ -609,7 +623,7 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
609
623
  operation: 'build-dataset-download',
610
624
  acceptedContracts: [],
611
625
  emittedContract: { schema: RISK_CALIBRATION_DATASET_SCHEMA, version: RISK_CALIBRATION_DATASET_VERSION, discriminator: null },
612
- shapeIds: ['risk-calibration.dataset.v2'],
626
+ shapeIds: [`risk-calibration.dataset.v${RISK_CALIBRATION_DATASET_VERSION}`],
613
627
  boundProfileIds: ['risk-calibration.dataset-bounds.v2'],
614
628
  hookIds: ['risk-calibration.browser.build-dataset', 'risk-calibration.browser.serialise-dataset'],
615
629
  serialisationProfileId: 'risk-calibration.dataset-json.v2',
@@ -624,9 +638,9 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
624
638
  id: 'risk-calibration.cli-detailed-json-stdout',
625
639
  plane: 'cli',
626
640
  operation: 'build-detailed-json-stdout',
627
- acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [RISK_CALIBRATION_DATASET_VERSION], mode: 'direct', discriminator: null }],
641
+ acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS], mode: 'direct', discriminator: null }],
628
642
  emittedContract: { schema: RISK_CALIBRATION_REPORT_SCHEMA, version: RISK_CALIBRATION_REPORT_VERSION, discriminator: { path: '$.mode', value: 'detailed' } },
629
- shapeIds: ['risk-calibration.dataset.v2', 'risk-calibration.report.v3-detailed'],
643
+ shapeIds: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.map(version => `risk-calibration.dataset.v${version}`), 'risk-calibration.report.v3-detailed'],
630
644
  boundProfileIds: ['risk-calibration.dataset-bounds.v2', 'risk-calibration.report-detailed.v3', 'risk-calibration.report-detailed-json-output.v3'],
631
645
  hookIds: ['risk-calibration.cli.parse-dataset', 'risk-calibration.cli.build-detailed', 'risk-calibration.cli.serialise-report'],
632
646
  serialisationProfileId: 'risk-calibration.report-json.v3',
@@ -641,9 +655,9 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
641
655
  id: 'risk-calibration.cli-dataset-read',
642
656
  plane: 'cli',
643
657
  operation: 'read-dataset',
644
- acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [RISK_CALIBRATION_DATASET_VERSION], mode: 'direct', discriminator: null }],
658
+ acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS], mode: 'direct', discriminator: null }],
645
659
  emittedContract: null,
646
- shapeIds: ['risk-calibration.dataset.v2'],
660
+ shapeIds: SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.map(version => `risk-calibration.dataset.v${version}`),
647
661
  boundProfileIds: ['risk-calibration.dataset-bounds.v2'],
648
662
  hookIds: ['risk-calibration.cli.parse-dataset'],
649
663
  serialisationProfileId: null,
@@ -658,9 +672,9 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
658
672
  id: 'risk-calibration.cli-detailed-terminal-stdout',
659
673
  plane: 'cli',
660
674
  operation: 'build-detailed-terminal-stdout',
661
- acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [RISK_CALIBRATION_DATASET_VERSION], mode: 'direct', discriminator: null }],
675
+ acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS], mode: 'direct', discriminator: null }],
662
676
  emittedContract: { schema: RISK_CALIBRATION_REPORT_SCHEMA, version: RISK_CALIBRATION_REPORT_VERSION, discriminator: { path: '$.mode', value: 'detailed' } },
663
- shapeIds: ['risk-calibration.dataset.v2', 'risk-calibration.report.v3-detailed'],
677
+ shapeIds: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.map(version => `risk-calibration.dataset.v${version}`), 'risk-calibration.report.v3-detailed'],
664
678
  boundProfileIds: ['risk-calibration.dataset-bounds.v2', 'risk-calibration.report-detailed.v3'],
665
679
  hookIds: ['risk-calibration.cli.parse-dataset', 'risk-calibration.cli.build-detailed', 'risk-calibration.cli.format-terminal'],
666
680
  serialisationProfileId: null,
@@ -675,9 +689,9 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
675
689
  id: 'risk-calibration.cli-summary-json-stdout',
676
690
  plane: 'cli',
677
691
  operation: 'build-summary-json-stdout',
678
- acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [RISK_CALIBRATION_DATASET_VERSION], mode: 'direct', discriminator: null }],
692
+ acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS], mode: 'direct', discriminator: null }],
679
693
  emittedContract: { schema: RISK_CALIBRATION_REPORT_SCHEMA, version: RISK_CALIBRATION_REPORT_VERSION, discriminator: { path: '$.mode', value: 'summary' } },
680
- shapeIds: ['risk-calibration.dataset.v2', 'risk-calibration.report.v3-summary'],
694
+ shapeIds: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.map(version => `risk-calibration.dataset.v${version}`), 'risk-calibration.report.v3-summary'],
681
695
  boundProfileIds: ['risk-calibration.dataset-bounds.v2', 'risk-calibration.report-summary-output.v3'],
682
696
  hookIds: ['risk-calibration.cli.parse-dataset', 'risk-calibration.cli.build-detailed', 'risk-calibration.shared.build-summary', 'risk-calibration.cli.serialise-report'],
683
697
  serialisationProfileId: 'risk-calibration.report-json.v3',
@@ -692,9 +706,9 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
692
706
  id: 'risk-calibration.cli-detailed-json-file',
693
707
  plane: 'cli',
694
708
  operation: 'build-detailed-json-file',
695
- acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [RISK_CALIBRATION_DATASET_VERSION], mode: 'direct', discriminator: null }],
709
+ acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS], mode: 'direct', discriminator: null }],
696
710
  emittedContract: { schema: RISK_CALIBRATION_REPORT_SCHEMA, version: RISK_CALIBRATION_REPORT_VERSION, discriminator: { path: '$.mode', value: 'detailed' } },
697
- shapeIds: ['risk-calibration.dataset.v2', 'risk-calibration.report.v3-detailed'],
711
+ shapeIds: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.map(version => `risk-calibration.dataset.v${version}`), 'risk-calibration.report.v3-detailed'],
698
712
  boundProfileIds: ['risk-calibration.dataset-bounds.v2', 'risk-calibration.report-detailed.v3', 'risk-calibration.report-detailed-json-output.v3', 'risk-calibration.cli-private-output.v1'],
699
713
  hookIds: ['risk-calibration.cli.parse-dataset', 'risk-calibration.cli.build-detailed', 'risk-calibration.cli.serialise-report', 'risk-calibration.cli.write-private-file'],
700
714
  serialisationProfileId: 'risk-calibration.report-json.v3',
@@ -709,9 +723,9 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
709
723
  id: 'risk-calibration.cli-detailed-terminal-file',
710
724
  plane: 'cli',
711
725
  operation: 'build-detailed-terminal-file',
712
- acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [RISK_CALIBRATION_DATASET_VERSION], mode: 'direct', discriminator: null }],
726
+ acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS], mode: 'direct', discriminator: null }],
713
727
  emittedContract: { schema: RISK_CALIBRATION_REPORT_SCHEMA, version: RISK_CALIBRATION_REPORT_VERSION, discriminator: { path: '$.mode', value: 'detailed' } },
714
- shapeIds: ['risk-calibration.dataset.v2', 'risk-calibration.report.v3-detailed'],
728
+ shapeIds: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.map(version => `risk-calibration.dataset.v${version}`), 'risk-calibration.report.v3-detailed'],
715
729
  boundProfileIds: ['risk-calibration.dataset-bounds.v2', 'risk-calibration.report-detailed.v3', 'risk-calibration.cli-private-output.v1'],
716
730
  hookIds: ['risk-calibration.cli.parse-dataset', 'risk-calibration.cli.build-detailed', 'risk-calibration.cli.format-terminal', 'risk-calibration.cli.write-private-file'],
717
731
  serialisationProfileId: null,
@@ -726,9 +740,9 @@ export const RISK_CALIBRATION_SCHEMA_LIFECYCLE = defineSchemaLifecycleFamily({
726
740
  id: 'risk-calibration.cli-summary-json-file',
727
741
  plane: 'cli',
728
742
  operation: 'build-summary-json-file',
729
- acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [RISK_CALIBRATION_DATASET_VERSION], mode: 'direct', discriminator: null }],
743
+ acceptedContracts: [{ schema: RISK_CALIBRATION_DATASET_SCHEMA, versions: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS], mode: 'direct', discriminator: null }],
730
744
  emittedContract: { schema: RISK_CALIBRATION_REPORT_SCHEMA, version: RISK_CALIBRATION_REPORT_VERSION, discriminator: { path: '$.mode', value: 'summary' } },
731
- shapeIds: ['risk-calibration.dataset.v2', 'risk-calibration.report.v3-summary'],
745
+ shapeIds: [...SUPPORTED_RISK_CALIBRATION_DATASET_VERSIONS.map(version => `risk-calibration.dataset.v${version}`), 'risk-calibration.report.v3-summary'],
732
746
  boundProfileIds: ['risk-calibration.dataset-bounds.v2', 'risk-calibration.report-summary-output.v3', 'risk-calibration.cli-private-output.v1'],
733
747
  hookIds: ['risk-calibration.cli.parse-dataset', 'risk-calibration.cli.build-detailed', 'risk-calibration.shared.build-summary', 'risk-calibration.cli.serialise-report', 'risk-calibration.cli.write-private-file'],
734
748
  serialisationProfileId: 'risk-calibration.report-json.v3',
@@ -1176,6 +1190,7 @@ const DATASET_EVIDENCE_SPEC = objectOf([
1176
1190
  { key: 'privacyProtected', value: BOOLEAN_SPEC, optional: true },
1177
1191
  { key: 'phishingLanguageMatch', value: boundedText(MAX_RISK_CALIBRATION_STRING_LENGTH), optional: true },
1178
1192
  { key: 'hasExternalFormAction', value: BOOLEAN_SPEC, optional: true },
1193
+ { key: 'hasExternalPasswordForm', value: BOOLEAN_SPEC, optional: true },
1179
1194
  { key: 'idnReferenceMatch', value: BOOLEAN_SPEC, optional: true },
1180
1195
  { key: 'pageBaselineMatch', value: BOOLEAN_SPEC, optional: true },
1181
1196
  { key: 'hasActiveBrandProfile', value: BOOLEAN_SPEC, optional: true },
@@ -640,11 +640,12 @@ function copyHook(value, index, budget) {
640
640
  }
641
641
  function copySerialisationProfile(value, index, budget) {
642
642
  const label = `Schema lifecycle serialisation profile ${index + 1}`;
643
- const source = ordinaryRecord(value, SERIALISATION_PROFILE_KEYS, label, budget);
643
+ const source = ordinaryRecord(value, SERIALISATION_PROFILE_KEYS, label, budget, ['compactFallback']);
644
644
  if (source.mediaType !== 'application/json'
645
645
  || source.encoding !== 'utf-8'
646
646
  || source.bom !== false
647
647
  || (source.indentSpaces !== 0 && source.indentSpaces !== 2)
648
+ || (source.compactFallback !== undefined && (source.compactFallback !== true || source.indentSpaces !== 2))
648
649
  || typeof source.terminalLf !== 'boolean'
649
650
  || (source.propertyOrder !== 'normalised_fixed' && source.propertyOrder !== 'source_insertion')
650
651
  || (source.integrity !== 'none'
@@ -664,6 +665,7 @@ function copySerialisationProfile(value, index, budget) {
664
665
  encoding: 'utf-8',
665
666
  bom: false,
666
667
  indentSpaces: source.indentSpaces,
668
+ ...(source.compactFallback === true ? { compactFallback: true } : {}),
667
669
  terminalLf: source.terminalLf,
668
670
  propertyOrder: source.propertyOrder,
669
671
  canonicalisation: source.canonicalisation === null
@@ -2,7 +2,7 @@ export { MAX_WEB_CAPTURE_MANIFEST_BYTES, WEB_CAPTURE_MANIFEST_SCHEMA, WEB_CAPTUR
2
2
  export const WEB_CAPTURE_DOM_DIGEST_SCHEMA = 'whoisleuth.dom-digest';
3
3
  export const WEB_CAPTURE_DOM_DIGEST_VERSION = 1;
4
4
  export const WEB_CAPTURE_COMPARISON_SCHEMA = 'whoisleuth.web-capture-comparison';
5
- export const WEB_CAPTURE_COMPARISON_VERSION = 4;
5
+ export const WEB_CAPTURE_COMPARISON_VERSION = 5;
6
6
  export const MAX_WEB_CAPTURE_DOM_DIGEST_BYTES = 1024 * 1024;
7
7
  export const MAX_WEB_CAPTURE_SCREENSHOT_BYTES = 10 * 1024 * 1024;
8
8
  export const MAX_WEB_CAPTURE_DOM_ELEMENTS = 20_000;
@@ -7,11 +7,13 @@ export const BRAND_PROFILE_SCHEMA = 'whoisleuth.brand-profiles';
7
7
  export const PUBLIC_BRAND_PROFILE_SCHEMA_VERSION = 6;
8
8
  // Historical readers retain a fixed published identity when the writer moves.
9
9
  export const PUBLISHED_V2_2_BRAND_PROFILE_SCHEMA_VERSION = 7;
10
- export const BRAND_PROFILE_SCHEMA_VERSION = 9;
10
+ export const BRAND_PROFILE_SCHEMA_VERSION = 11;
11
11
  export const BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([
12
12
  PUBLIC_BRAND_PROFILE_SCHEMA_VERSION,
13
13
  PUBLISHED_V2_2_BRAND_PROFILE_SCHEMA_VERSION,
14
14
  8,
15
+ 9,
16
+ 10,
15
17
  BRAND_PROFILE_SCHEMA_VERSION,
16
18
  ]);
17
19
  export const SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS = BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS;
@@ -21,6 +23,17 @@ export const MAX_PROFILE_VALUE_INPUTS = MAX_PROFILE_VALUES * 4;
21
23
  export const MAX_PROFILE_STORE_BYTES = 4 * 1024 * 1024;
22
24
  export const MAX_PROFILE_IMPORT_BYTES = MAX_PROFILE_STORE_BYTES * 8;
23
25
  export const MAX_PROFILE_NAME_LENGTH = 100;
26
+ export const MAX_CANDIDATE_OBSERVATIONS = 200;
27
+ export const MAX_CANDIDATE_MATCHES = 20;
28
+ export const MAX_CANDIDATE_SOURCES = 12;
29
+ export const MAX_CANDIDATE_TEXT = 300;
30
+ export const MAX_CANDIDATE_EXCEPTIONS = 200;
31
+ export const MAX_CANDIDATE_EXCEPTION_HISTORY = 8;
32
+ export const MAX_BRAND_KEYWORD_CAMPAIGNS = 20;
33
+ export const MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY = 8;
34
+ export const MAX_BRAND_KEYWORD_TERMS = 20;
35
+ export const MAX_BRAND_KEYWORD_TERM_LENGTH = 80;
36
+ export const MAX_WATCH_CONTEXTS = 20;
24
37
  export const MAX_PROFILE_TEXT_LENGTH = 200;
25
38
  export const MAX_PROFILE_DOMAIN_LENGTH = 253;
26
39
  export const MAX_PROFILE_URL_LENGTH = 2_048;
@@ -47,14 +60,21 @@ export const MAX_CAMPAIGN_IMPORT_BYTES = 2 * 1024 * 1024;
47
60
  export const MAX_CAMPAIGN_INPUT_RECORDS = 500;
48
61
  export const MAX_CAMPAIGN_STORE_BYTES = 512 * 1024;
49
62
  export const WATCHLIST_SCHEMA = 'whoisleuth.watchlists';
50
- export const WATCHLIST_SCHEMA_VERSION = 4;
51
- export const WATCHLIST_BROWSER_SUPPORTED_VERSIONS = Object.freeze([2, 3, WATCHLIST_SCHEMA_VERSION]);
63
+ export const WATCHLIST_SCHEMA_VERSION = 6;
64
+ export const WATCHLIST_BROWSER_SUPPORTED_VERSIONS = Object.freeze([2, 3, 4, 5, WATCHLIST_SCHEMA_VERSION]);
52
65
  export const WATCHLIST_EXPORT_SUPPORTED_VERSIONS = WATCHLIST_BROWSER_SUPPORTED_VERSIONS;
53
66
  export const MAX_WATCHLISTS = 100;
54
67
  export const MAX_WATCHLIST_INPUTS = MAX_WATCHLISTS * 4;
55
68
  export const MAX_WATCHLIST_NAME_LENGTH = 100;
56
69
  export const MAX_WATCHLIST_STORE_BYTES = 2 * 1024 * 1024;
57
70
  export const MAX_WATCHLIST_IMPORT_BYTES = 2 * 1024 * 1024;
71
+ // Recovery metadata is not new analyst data. Reserve only its exact JSON cost;
72
+ // the store writer still enforces the ordinary budget after subtracting it.
73
+ export const WATCHLIST_RECOVERY_METADATA = Object.freeze({ membershipRecovery: 'legacy_overflow' });
74
+ export const WATCHLIST_RECOVERY_METADATA_BYTES = JSON.stringify(WATCHLIST_RECOVERY_METADATA).length - 1;
75
+ export const MAX_WATCHLIST_READ_BYTES = MAX_WATCHLIST_STORE_BYTES + MAX_WATCHLISTS * WATCHLIST_RECOVERY_METADATA_BYTES;
76
+ // Date.toISOString() produces at most 27 ASCII characters, including extended years.
77
+ export const MAX_WATCHLIST_PORTABLE_BYTES = MAX_WATCHLIST_READ_BYTES + JSON.stringify({ exportedAt: '+999999-12-31T23:59:59.999Z' }).length - 1;
58
78
  export const MAX_WATCHLIST_HISTORY_EVENTS = 12;
59
79
  export const MAX_WATCHLIST_CHANGES_PER_EVENT = 500;
60
80
  export const MAX_WATCHLIST_DOMAINS = 2_000;
@@ -82,9 +102,9 @@ export const MAX_CT_HISTORY_EVER_SEEN_DOMAINS = 1_000;
82
102
  export const MAX_CT_HISTORY_QUERY_LENGTH = 200;
83
103
  export const MAX_CT_HISTORY_STORE_BYTES = 1024 * 1024;
84
104
  export const DETECTION_RULE_SCHEMA = 'whoisleuth.detection-rules';
85
- export const DETECTION_RULE_SCHEMA_VERSION = 1;
86
- export const DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([DETECTION_RULE_SCHEMA_VERSION]);
87
- export const DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS = Object.freeze([DETECTION_RULE_SCHEMA_VERSION]);
105
+ export const DETECTION_RULE_SCHEMA_VERSION = 2;
106
+ export const DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS = Object.freeze([1, DETECTION_RULE_SCHEMA_VERSION]);
107
+ export const DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS = DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS;
88
108
  export const MAX_DETECTION_RULES = 50;
89
109
  export const MAX_RULE_CONDITIONS = 8;
90
110
  export const MAX_RULE_INPUT_RECORDS = 250;
@@ -114,8 +134,8 @@ export const MAX_WEBSITE_SNAPSHOTS_PER_DOMAIN = 12;
114
134
  export const MAX_WEBSITE_SNAPSHOT_STORE_BYTES = 512 * 1024;
115
135
  export const MAX_WEBSITE_SNAPSHOT_IMPORT_BYTES = 768 * 1024;
116
136
  export const BULK_SESSION_SCHEMA = 'whoisleuth.bulk-sessions';
117
- export const BULK_SESSION_SCHEMA_VERSION = 6;
118
- export const BULK_SESSION_BROWSER_SUPPORTED_VERSIONS = Object.freeze([4, 5, BULK_SESSION_SCHEMA_VERSION]);
137
+ export const BULK_SESSION_SCHEMA_VERSION = 7;
138
+ export const BULK_SESSION_BROWSER_SUPPORTED_VERSIONS = Object.freeze([4, 5, 6, BULK_SESSION_SCHEMA_VERSION]);
119
139
  export const SUPPORTED_BULK_SESSION_SCHEMA_VERSIONS = BULK_SESSION_BROWSER_SUPPORTED_VERSIONS;
120
140
  export const MAX_BULK_SESSIONS = 10;
121
141
  export const MAX_BULK_SESSION_ROWS = 2_000;
@@ -180,12 +200,14 @@ export const WORKSPACE_PORTABILITY_IDENTITY_CONSTANTS = Object.freeze([
180
200
  export const WORKSPACE_PORTABILITY_BOUND_CONSTANTS = Object.freeze([
181
201
  'MAX_PROFILES', 'MAX_PROFILE_VALUES', 'MAX_PROFILE_VALUE_INPUTS', 'MAX_PROFILE_STORE_BYTES', 'MAX_PROFILE_IMPORT_BYTES',
182
202
  'MAX_PROFILE_NAME_LENGTH', 'MAX_PROFILE_TEXT_LENGTH', 'MAX_PROFILE_DOMAIN_LENGTH', 'MAX_PROFILE_URL_LENGTH', 'MAX_PROFILE_TLD_LENGTH',
203
+ 'MAX_BRAND_KEYWORD_CAMPAIGNS', 'MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY', 'MAX_BRAND_KEYWORD_TERMS', 'MAX_BRAND_KEYWORD_TERM_LENGTH',
183
204
  'MAX_OFFICIAL_CHANNELS', 'MAX_RIGHTS_REFERENCES', 'MAX_DKIM_SELECTOR_LENGTH',
184
205
  'MAX_DKIM_SELECTORS', 'MAX_PROTECTION_ATTESTATIONS', 'MAX_DESIRED_POSTURE_BASELINES', 'MAX_DESIRED_POSTURE_RECORDS',
185
206
  'MAX_DESIRED_POSTURE_SUPPRESSIONS', 'MAX_DESIRED_POSTURE_OBSERVATIONS', 'MAX_DESIRED_POSTURE_CHANGE_WINDOWS',
186
207
  'MAX_CAMPAIGNS', 'MAX_CAMPAIGN_DOMAINS', 'MAX_CAMPAIGN_NAME_LENGTH', 'MAX_CAMPAIGN_DESCRIPTION_LENGTH',
187
208
  'MAX_CAMPAIGN_IMPORT_BYTES', 'MAX_CAMPAIGN_INPUT_RECORDS', 'MAX_CAMPAIGN_STORE_BYTES',
188
209
  'MAX_WATCHLISTS', 'MAX_WATCHLIST_INPUTS', 'MAX_WATCHLIST_NAME_LENGTH', 'MAX_WATCHLIST_STORE_BYTES', 'MAX_WATCHLIST_IMPORT_BYTES',
210
+ 'MAX_WATCHLIST_READ_BYTES', 'MAX_WATCHLIST_PORTABLE_BYTES',
189
211
  'MAX_WATCHLIST_HISTORY_EVENTS', 'MAX_WATCHLIST_CHANGES_PER_EVENT', 'MAX_WATCHLIST_DOMAINS', 'MAX_WATCHLIST_INPUT_RECORDS',
190
212
  'MAX_WATCHLIST_NAMESERVERS', 'MAX_WATCHLIST_MUTATION_TYPES', 'MAX_WATCHLIST_HISTORY_DOMAIN_OPTIONS',
191
213
  'MAX_SHORTLIST_ENTRIES', 'MAX_SHORTLIST_INPUTS', 'MAX_SHORTLIST_STORE_BYTES', 'MAX_SHORTLIST_IMPORT_BYTES', 'MAX_SHORTLIST_FACTORS',
@@ -229,7 +251,7 @@ export const BRAND_PROFILE_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
229
251
  supportedVersions: BRAND_PROFILE_BROWSER_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
230
252
  futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current', writeSemantics: 'normalized_rewrite',
231
253
  byteBudget: MAX_PROFILE_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
232
- note: 'Published versions 6, 7 and 8 migrate directly to the current profile envelope. Version 9 preserves earlier page baselines while admitting independently versioned native-tree fingerprints; incompatible fingerprint algorithms are not compared.',
254
+ note: 'Versions 6–10 remain readable without invented keyword campaigns. Version 11 adds bounded, revisioned, time-limited Brand keyword intent without collection authority; candidate provenance and independent page-fingerprint algorithms remain intact.',
233
255
  });
234
256
  export const CAMPAIGN_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
235
257
  id: 'browser.campaigns', kind: 'browser_store', schema: null, currentVersion: CAMPAIGN_SCHEMA_VERSION,
@@ -242,7 +264,7 @@ export const WATCHLIST_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
242
264
  id: 'browser.watchlists', kind: 'browser_store', schema: null, currentVersion: WATCHLIST_SCHEMA_VERSION,
243
265
  supportedVersions: WATCHLIST_BROWSER_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
244
266
  futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current', writeSemantics: 'normalized_rewrite',
245
- byteBudget: MAX_WATCHLIST_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
267
+ byteBudget: MAX_WATCHLIST_READ_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
246
268
  note: 'Public version 2 remains readable without inferred collection outcomes. Version 3 retains explicit page and favicon collection quality; unversioned maps are outside the compatibility boundary.',
247
269
  });
248
270
  export const SHORTLIST_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
@@ -262,7 +284,7 @@ export const CT_HISTORY_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
262
284
  export const DETECTION_RULE_BROWSER_COMPATIBILITY = defineSchemaCompatibility({
263
285
  id: 'browser.detection-rules', kind: 'browser_store', schema: null, currentVersion: DETECTION_RULE_SCHEMA_VERSION,
264
286
  supportedVersions: DETECTION_RULE_BROWSER_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
265
- futureVersionBehavior: 'preserve_without_write', migration: 'exact_current_only', writeSemantics: 'normalized_rewrite',
287
+ futureVersionBehavior: 'preserve_without_write', migration: 'normalize_to_current', writeSemantics: 'normalized_rewrite',
266
288
  byteBudget: MAX_RULE_STORE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
267
289
  note: 'Only allowlisted structured rule fields and operators survive normalisation; the schema string belongs to portable exports.',
268
290
  });
@@ -306,7 +328,7 @@ export const BRAND_PROFILE_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
306
328
  supportedVersions: SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, acceptsUnversionedLegacy: false,
307
329
  futureVersionBehavior: 'reject', migration: 'normalize_to_current', writeSemantics: 'non_destructive_merge',
308
330
  byteBudget: MAX_PROFILE_IMPORT_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
309
- note: 'Supported exports merge non-destructively by bounded normalised profile identity.',
331
+ note: 'Supported exports merge non-destructively by bounded profile identity. Candidate decisions cannot be reassigned to a different Brand identifier with the same name.',
310
332
  });
311
333
  export const CAMPAIGN_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
312
334
  id: 'export.campaigns', kind: 'export', schema: CAMPAIGN_SCHEMA, currentVersion: CAMPAIGN_SCHEMA_VERSION,
@@ -319,8 +341,8 @@ export const WATCHLIST_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
319
341
  id: 'export.watchlists', kind: 'export', schema: WATCHLIST_SCHEMA, currentVersion: WATCHLIST_SCHEMA_VERSION,
320
342
  supportedVersions: WATCHLIST_EXPORT_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
321
343
  futureVersionBehavior: 'reject', migration: 'normalize_to_current', writeSemantics: 'non_destructive_merge',
322
- byteBudget: MAX_WATCHLIST_IMPORT_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
323
- note: 'Public version 2 and current version 3 merge non-destructively. Historical records have unknown collection quality; incomplete current checks cannot erase a usable baseline.',
344
+ byteBudget: MAX_WATCHLIST_PORTABLE_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
345
+ note: 'Versions 2–5 remain readable. Version 6 preserves older membership overflow as paused, exportable records without activating or pruning domains. New active membership remains bounded. Brand contexts and incomplete-check baselines remain preserved.',
324
346
  });
325
347
  export const SHORTLIST_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
326
348
  id: 'export.shortlist', kind: 'export', schema: SHORTLIST_SCHEMA, currentVersion: SHORTLIST_SCHEMA_VERSION,
@@ -332,7 +354,7 @@ export const SHORTLIST_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
332
354
  export const DETECTION_RULE_EXPORT_COMPATIBILITY = defineSchemaCompatibility({
333
355
  id: 'export.detection-rules', kind: 'export', schema: DETECTION_RULE_SCHEMA, currentVersion: DETECTION_RULE_SCHEMA_VERSION,
334
356
  supportedVersions: DETECTION_RULE_EXPORT_SUPPORTED_VERSIONS, acceptsUnversionedLegacy: false,
335
- futureVersionBehavior: 'reject', migration: 'exact_current_only', writeSemantics: 'non_destructive_merge',
357
+ futureVersionBehavior: 'reject', migration: 'normalize_to_current', writeSemantics: 'non_destructive_merge',
336
358
  byteBudget: MAX_RULE_IMPORT_BYTES, owner: WORKSPACE_CONTRACT_OWNER,
337
359
  note: 'Non-destructive rule merge; imported conditions remain allowlisted and non-executable.',
338
360
  });
@@ -421,6 +443,15 @@ const WORKSPACE_LIFECYCLE_DEFINITIONS = Object.freeze([
421
443
  itemBounds: [
422
444
  { id: 'profiles', path: 'profiles', maximum: MAX_PROFILES, handling: 'truncate' },
423
445
  { id: 'profile-values', path: 'profiles[].values', maximum: MAX_PROFILE_VALUES, handling: 'truncate' },
446
+ { id: 'candidate-observations', path: 'profiles[].candidateObservations', maximum: MAX_CANDIDATE_OBSERVATIONS, handling: 'truncate' },
447
+ { id: 'candidate-matches', path: 'profiles[].candidateObservations[].matches', maximum: MAX_CANDIDATE_MATCHES, handling: 'reject' },
448
+ { id: 'candidate-sources', path: 'profiles[].candidateObservations[].sources', maximum: MAX_CANDIDATE_SOURCES, handling: 'reject' },
449
+ { id: 'candidate-exceptions', path: 'profiles[].candidateExceptions', maximum: MAX_CANDIDATE_EXCEPTIONS, handling: 'truncate' },
450
+ { id: 'exception-history', path: 'profiles[].candidateExceptions[].history', maximum: MAX_CANDIDATE_EXCEPTION_HISTORY, handling: 'truncate' },
451
+ { id: 'keyword-campaigns', path: 'profiles[].keywordCampaigns', maximum: MAX_BRAND_KEYWORD_CAMPAIGNS, handling: 'reject' },
452
+ { id: 'keyword-campaign-history', path: 'profiles[].keywordCampaigns[].history', maximum: MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY, handling: 'reject' },
453
+ { id: 'keyword-positive-terms', path: 'profiles[].keywordCampaigns[].positiveTerms', maximum: MAX_BRAND_KEYWORD_TERMS, handling: 'reject' },
454
+ { id: 'keyword-negative-terms', path: 'profiles[].keywordCampaigns[].negativeTerms', maximum: MAX_BRAND_KEYWORD_TERMS, handling: 'reject' },
424
455
  ],
425
456
  portable: {
426
457
  descriptor: BRAND_PROFILE_EXPORT_COMPATIBILITY,
@@ -466,14 +497,17 @@ const WORKSPACE_LIFECYCLE_DEFINITIONS = Object.freeze([
466
497
  { id: 'watchlists', path: 'watchlists', maximum: MAX_WATCHLISTS, handling: 'truncate' },
467
498
  { id: 'domains', path: 'watchlists[].results', maximum: MAX_WATCHLIST_DOMAINS, handling: 'drop_value' },
468
499
  { id: 'history', path: 'watchlists[].history', maximum: MAX_WATCHLIST_HISTORY_EVENTS, handling: 'truncate' },
500
+ { id: 'domain-contexts', path: 'watchlists[].domainMetadata', maximum: MAX_WATCHLIST_DOMAINS, handling: 'reject' },
501
+ { id: 'brand-contexts', path: 'watchlists[].domainMetadata[].contexts', maximum: MAX_WATCH_CONTEXTS, handling: 'truncate' },
469
502
  ],
470
503
  portable: {
471
504
  descriptor: WATCHLIST_EXPORT_COMPATIBILITY,
472
505
  schema: WATCHLIST_SCHEMA,
473
506
  builder: 'buildWatchlistExport',
474
507
  merger: 'mergeWatchlistStores',
475
- serializerHookId: 'workspace.portable.json',
508
+ serializerHookId: 'workspace.portable.watchlist-json',
476
509
  indentSpaces: 2,
510
+ compactFallback: true,
477
511
  terminalLf: false,
478
512
  },
479
513
  },
@@ -649,6 +683,34 @@ const WORKSPACE_LIFECYCLE_DEFINITIONS = Object.freeze([
649
683
  },
650
684
  ]);
651
685
  const WORKSPACE_LIFECYCLE_FIXTURE_SOURCE = Object.freeze([
686
+ { id: 'workspace.browser.brand.v11', path: 'test/fixtures/workspace-lifecycle/browser-brand-v11.json', bytes: 38, sha256: 'c727cd501774f0f89a39f399a01216065b6ed7048bfed97bbcdaf10e651a7133', schema: 'whoisleuth.browser.brand-profile-store', version: 11 },
687
+ { id: 'workspace.portable.brand.v11', path: 'test/fixtures/workspace-lifecycle/portable-brand-v11.json', bytes: 123, sha256: 'c5023e1eebe187cf355c4fb53b7d8a755dc0ffb01a3deeb9b9dd7032007933d9', schema: 'whoisleuth.brand-profiles', version: 11 },
688
+ { id: 'workspace.browser.watchlist.v6', path: 'test/fixtures/workspace-lifecycle/browser-watchlist-v6.json', bytes: 76, sha256: '16df0f813b17f1765862f5c2ff41c2db9c7d6d76e75f24746aa6d75b59d3f02e', schema: 'whoisleuth.browser.watchlist-store', version: 6 },
689
+ { id: 'workspace.portable.watchlist.v6', path: 'test/fixtures/workspace-lifecycle/portable-watchlist-v6.json', bytes: 120, sha256: 'ced22d0d16e839e0b4625f8799eef30a776908abf3af998de6fad5b549898987', schema: 'whoisleuth.watchlists', version: 6 },
690
+ { id: 'workspace.browser.brand.v10', path: 'test/fixtures/workspace-lifecycle/browser-brand-v10.json', bytes: 38, sha256: 'bebf3a72ba57c06246e6169ebd9a597eb5b867508db85e1db0e3d763bc0839bc', schema: 'whoisleuth.browser.brand-profile-store', version: 10 },
691
+ { id: 'workspace.portable.brand.v10', path: 'test/fixtures/workspace-lifecycle/portable-brand-v10.json', bytes: 123, sha256: '85cd7c58696f8219484ac88210ad7ab4a0ad1c9798e8cb02ff6ebb3fd2c82d8a', schema: 'whoisleuth.brand-profiles', version: 10 },
692
+ { id: 'workspace.browser.watchlist.v5', path: 'test/fixtures/workspace-lifecycle/browser-watchlist-v5.json', bytes: 76, sha256: '11eabd20f952611307a5c84291c7892c15d3748f7ada5549813e1d705967c2c0', schema: 'whoisleuth.browser.watchlist-store', version: 5 },
693
+ { id: 'workspace.portable.watchlist.v5', path: 'test/fixtures/workspace-lifecycle/portable-watchlist-v5.json', bytes: 120, sha256: '732d4bda46e907bdd4d343ce36999a2787a0057029ee4dba0c4ef462f2822fed', schema: 'whoisleuth.watchlists', version: 5 },
694
+ {
695
+ id: 'workspace.browser.detection.v2', path: 'test/fixtures/workspace-lifecycle/browser-detection-v2.json',
696
+ bytes: 34, sha256: '2db6ae8eb9d24cb86924a7020c6f3eebfa41b5fc5848a53e239465db622c2312',
697
+ schema: 'whoisleuth.browser.detection-rule-store', version: 2,
698
+ },
699
+ {
700
+ id: 'workspace.portable.detection.v2', path: 'test/fixtures/workspace-lifecycle/portable-detection-v2.json',
701
+ bytes: 293, sha256: 'bf4409e18ef0b721f5a7e2f14a06f2fada88287114ec17775fef42e7ed38072a',
702
+ schema: 'whoisleuth.detection-rules', version: 2,
703
+ },
704
+ {
705
+ id: 'workspace.browser.bulk.v7', path: 'test/fixtures/workspace-lifecycle/browser-bulk-v7.json',
706
+ bytes: 77, sha256: '6b02cbd48a939a34d8f292da2b6bc8e8b59f3ed1b9c106a8e9e271ec0e949459',
707
+ schema: 'whoisleuth.browser.bulk-session-store', version: 7,
708
+ },
709
+ {
710
+ id: 'workspace.portable.bulk.v7', path: 'test/fixtures/workspace-lifecycle/portable-bulk-v7.json',
711
+ bytes: 338, sha256: 'f7e14d866eeb4e94b5390086e278348bfa0610ace72776fee4fbf24871ab3393',
712
+ schema: 'whoisleuth.bulk-sessions', version: 7,
713
+ },
652
714
  {
653
715
  id: 'workspace.browser.watchlist.v4', path: 'test/fixtures/workspace-lifecycle/browser-watchlist-v4.json',
654
716
  bytes: 76, sha256: '059930ee54d1bc65ac68ef6d3a8ac8cd8e53f682fe9a857512e2b6c57e93d5fe',
@@ -1146,6 +1208,13 @@ const WORKSPACE_LIFECYCLE_HOOKS = Object.freeze([
1146
1208
  module: WORKSPACE_CONTRACT_OWNER,
1147
1209
  exportName: 'serialiseWorkspacePortableJson',
1148
1210
  },
1211
+ {
1212
+ id: 'workspace.portable.watchlist-json',
1213
+ role: 'serialiser',
1214
+ runtime: 'shared',
1215
+ module: 'packages/workspace/watchlist-store.mts',
1216
+ exportName: 'serializeWatchlistExport',
1217
+ },
1149
1218
  {
1150
1219
  id: 'workspace.portable.json-line',
1151
1220
  role: 'serialiser',
@@ -1254,6 +1323,7 @@ function workspacePortableSerialisation(definition) {
1254
1323
  encoding: 'utf-8',
1255
1324
  bom: false,
1256
1325
  indentSpaces: definition.portable.indentSpaces,
1326
+ ...(definition.portable.compactFallback ? { compactFallback: true } : {}),
1257
1327
  terminalLf: definition.portable.terminalLf,
1258
1328
  propertyOrder: 'normalised_fixed',
1259
1329
  canonicalisation: null,
@@ -35,7 +35,7 @@ export function webCollectionQualityForCapture(value, depth) {
35
35
  return capturedWebCollectionQuality('not_collected', 'not_collected');
36
36
  return normalizeWebCollectionQuality(value) ?? capturedWebCollectionQuality('unknown', 'unknown');
37
37
  }
38
- const PAGE_FIELDS = new Set(['activityStatus', 'pageTitle', 'hasPasswordField', 'hasExternalFormAction', 'phishingLanguageMatch', 'pageBaselineMatch']);
38
+ const PAGE_FIELDS = new Set(['activityStatus', 'pageTitle', 'hasPasswordField', 'hasExternalFormAction', 'hasExternalPasswordForm', 'phishingLanguageMatch', 'pageBaselineMatch']);
39
39
  const FAVICON_FIELDS = new Set(['faviconHash', 'faviconPHash', 'faviconMatch', 'faviconNearMatch']);
40
40
  const COMBINED_FIELDS = new Set(['riskScore', 'riskFactors', 'riskModelVersion', 'opportunityScore', 'opportunityFactors', 'reusesOfficialAssets']);
41
41
  /** Missing historical quality never proves that a false/null value was observed. */
@@ -0,0 +1,60 @@
1
+ export const CREDENTIAL_CATEGORY_NAMES = ['password', 'email', 'username', 'one_time_code', 'payment'];
2
+ export const MAX_CREDENTIAL_FORMS = 50;
3
+ export const MAX_CREDENTIAL_INPUTS = 500;
4
+ export const MAX_FORM_DESTINATIONS = 10;
5
+ function record(value) {
6
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
7
+ }
8
+ function keys(value, expected) {
9
+ return Object.keys(value).length === expected.length && expected.every(key => Object.hasOwn(value, key));
10
+ }
11
+ const count = (value, max) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= max;
12
+ export function validCredentialFormAttribution(value) {
13
+ if (!record(value) || !keys(value, ['complete', 'unassociatedInputs', 'forms']) || typeof value.complete !== 'boolean'
14
+ || !count(value.unassociatedInputs, MAX_CREDENTIAL_INPUTS) || !Array.isArray(value.forms) || value.forms.length > MAX_CREDENTIAL_FORMS)
15
+ return false;
16
+ let classified = 0;
17
+ for (const [index, form] of value.forms.entries()) {
18
+ if (!record(form) || !keys(form, ['index', 'categories', 'destinations']) || form.index !== index + 1
19
+ || !record(form.categories) || !keys(form.categories, CREDENTIAL_CATEGORY_NAMES)
20
+ || !CREDENTIAL_CATEGORY_NAMES.every(key => count(form.categories[key], MAX_CREDENTIAL_INPUTS))
21
+ || !Array.isArray(form.destinations) || !form.destinations.length || form.destinations.length > MAX_FORM_DESTINATIONS)
22
+ return false;
23
+ classified += Math.max(...CREDENTIAL_CATEGORY_NAMES.map(key => Number(form.categories[key])));
24
+ const seen = new Set();
25
+ for (const destination of form.destinations) {
26
+ if (!record(destination) || !keys(destination, ['relationship', 'origin']))
27
+ return false;
28
+ if (destination.relationship === 'unknown' || destination.relationship === 'no_submission') {
29
+ if (destination.origin !== null || (destination.relationship === 'unknown' && value.complete))
30
+ return false;
31
+ }
32
+ else if (destination.relationship === 'external' || destination.relationship === 'same_origin') {
33
+ if (typeof destination.origin !== 'string' || destination.origin.length > 320)
34
+ return false;
35
+ try {
36
+ const url = new URL(destination.origin);
37
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== destination.origin || url.username || url.password)
38
+ return false;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ else
45
+ return false;
46
+ const identity = `${destination.relationship}:${destination.origin}`;
47
+ if (seen.has(identity))
48
+ return false;
49
+ seen.add(identity);
50
+ }
51
+ }
52
+ return classified + value.unassociatedInputs <= MAX_CREDENTIAL_INPUTS && (!value.complete || value.unassociatedInputs === 0);
53
+ }
54
+ export function externalPasswordFormObservation(value) {
55
+ if (!validCredentialFormAttribution(value))
56
+ return null;
57
+ if (value.forms.some(form => form.categories.password > 0 && form.destinations.some(destination => destination.relationship === 'external')))
58
+ return true;
59
+ return value.complete ? false : null;
60
+ }