@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,5 +1,20 @@
1
- import { PUBLIC_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
1
+ import { COLLECTION_RECEIPT_CASE_SCHEMA_VERSION, PUBLIC_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, MAX_CASE_IMPORT_BYTES } from "../contracts/case-portability.mjs";
2
2
  import { caseStatusIsClosed } from "./case-record-decisions.mjs";
3
+ import { caseEvidenceWithoutCollectionReceipts } from "./case-evidence-model.mjs";
4
+ import { assertBoundedJsonStructure, boundedJsonLimitsForBytes, scanBoundedJson } from "../analysis/bounded-json.mjs";
5
+ /** Check the whole shared envelope, including otherwise unrecognised nesting.
6
+ * Writer projection alone cannot enforce the audience of an imported pack. */
7
+ export function assertCaseCollectionReceiptAudience(root, audience) {
8
+ if (audience === 'internal')
9
+ return;
10
+ const limits = boundedJsonLimitsForBytes(MAX_CASE_IMPORT_BYTES);
11
+ assertBoundedJsonStructure(root, 'Shared Case pack', limits);
12
+ scanBoundedJson(JSON.stringify(root), limits, entry => {
13
+ if (entry.kind === 'key' && (entry.value === 'collectionReceipt' || entry.value === 'checkpointCollectionReceipts')) {
14
+ throw new TypeError('Shared Case packs cannot contain private collection receipts.');
15
+ }
16
+ });
17
+ }
3
18
  const PRESERVE = Object.freeze({
4
19
  durable: 'preserve', internal: 'preserve', trusted: 'preserve', public: 'preserve',
5
20
  });
@@ -101,16 +116,38 @@ const CASE_FIELD_RULES = Object.freeze({
101
116
  },
102
117
  }),
103
118
  source: preservedField('source'),
104
- evidenceHistory: fieldRule('evidenceHistory', PRESERVE, (record) => structuredClone(record.evidenceHistory)),
105
- evidencePins: fieldRule('evidencePins', PRESERVE, (record) => structuredClone(record.evidencePins)),
119
+ evidenceHistory: fieldRule('evidenceHistory', SHARED_REDACT, (record, profile) => (profile === 'trusted' || profile === 'public'
120
+ ? caseEvidenceWithoutCollectionReceipts(record) : structuredClone(record.evidenceHistory)), {
121
+ nestedSensitiveFields: ['collectionReceipt'],
122
+ audienceExclusions: {
123
+ trusted: { label: 'Private collection receipts', order: 4, sinceVersion: COLLECTION_RECEIPT_CASE_SCHEMA_VERSION },
124
+ public: { label: 'Private collection receipts', order: 4, sinceVersion: COLLECTION_RECEIPT_CASE_SCHEMA_VERSION },
125
+ },
126
+ }),
127
+ evidencePins: fieldRule('evidencePins', PUBLIC_REDACT, (record, profile) => structuredClone(record.evidencePins).map(pin => {
128
+ if (profile !== 'public')
129
+ return pin;
130
+ const { responseObject: _scope, infrastructureObservation: _infrastructure, ...publicPin } = pin;
131
+ return publicPin;
132
+ }), { nestedSensitiveFields: ['responseObject', 'infrastructureObservation'], audienceExclusions: { public: { label: 'Exact-object evidence scope and infrastructure observations', order: 4, sinceVersion: 18 } } }),
133
+ checkpointCollectionReceipts: fieldRule('checkpointCollectionReceipts', SHARED_EXCLUDE, (record, profile) => (profile === 'trusted' || profile === 'public' ? undefined : structuredClone(record.checkpointCollectionReceipts)), { audienceExclusions: {
134
+ trusted: { label: 'Private collection receipts', order: 4, sinceVersion: COLLECTION_RECEIPT_CASE_SCHEMA_VERSION },
135
+ public: { label: 'Private collection receipts', order: 4, sinceVersion: COLLECTION_RECEIPT_CASE_SCHEMA_VERSION },
136
+ } }),
106
137
  evidenceLinks: fieldRule('evidenceLinks', PUBLIC_EXCLUDE, (record, profile) => (profile === 'public' ? undefined : structuredClone(record.evidenceLinks)), { audienceExclusions: { public: { label: 'Analyst evidence relationships', order: 4, sinceVersion: EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION } } }),
107
138
  decisions: fieldRule('decisions', PRESERVE, (record) => structuredClone(record.decisions)),
108
139
  actions: fieldRule('actions', TRUSTED_REDACT_PUBLIC_EXCLUDE, (record, profile) => (profile === 'public'
109
140
  ? []
110
141
  : profile === 'trusted'
111
- ? structuredClone(record.actions).map((item) => ({ ...item, recipient: '[redacted]' }))
142
+ ? structuredClone(record.actions).map((item) => {
143
+ const { correction: _privateCorrection, ...shared } = item;
144
+ return { ...shared, recipient: '[redacted]', history: shared.history.map(event => {
145
+ const { packetReceipt: _privateReceipt, ...sharedEvent } = event;
146
+ return sharedEvent;
147
+ }) };
148
+ })
112
149
  : structuredClone(record.actions)), {
113
- nestedSensitiveFields: ['recipient'],
150
+ nestedSensitiveFields: ['recipient', 'responseObjects', 'correction', 'packetReceipt'],
114
151
  audienceExclusions: {
115
152
  trusted: { label: 'Recipient values', order: 2 },
116
153
  public: { label: 'Actions and recipient values', order: 3 },
@@ -191,6 +228,7 @@ const OUTSIDE_SCHEMA_EXCLUSIONS = Object.freeze({
191
228
  });
192
229
  function currentAudienceExclusions(audience, caseVersion) {
193
230
  const entries = [
231
+ ...(audience === 'trusted' && caseVersion >= 19 ? [{ label: 'Private correction content and exact delivery receipts', order: 2 }] : []),
194
232
  ...Object.values(CASE_FIELD_RULES).flatMap((rule) => (rule.audienceExclusions[audience] ? [rule.audienceExclusions[audience]] : [])),
195
233
  OUTSIDE_SCHEMA_EXCLUSIONS[audience],
196
234
  ];
@@ -1,7 +1,29 @@
1
- import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, PUBLISHED_V2_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
1
+ import { EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, PUBLISHED_V2_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, OBJECT_RESPONSE_CASE_SCHEMA_VERSION, DELIVERY_CORRECTION_CASE_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
2
2
  /** Adapt declared historical fields before ordinary current-record recovery. */
3
3
  export function caseRecordVersionInput(record, sourceVersion) {
4
4
  const declared = sourceVersion != null;
5
+ if (declared && sourceVersion < DELIVERY_CORRECTION_CASE_SCHEMA_VERSION && Array.isArray(record.actions)
6
+ && record.actions.some(action => action && typeof action === 'object' && (action.correction !== undefined
7
+ || Array.isArray(action.history) && action.history.some((event) => event?.packetReceipt !== undefined)))) {
8
+ throw new TypeError('Delivery receipts and corrections require Case schema 19; historical deliveries were not reinterpreted.');
9
+ }
10
+ if (declared && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION) {
11
+ const items = (value) => Array.isArray(value) ? value.filter((item) => !!item && typeof item === 'object' && !Array.isArray(item)) : [];
12
+ const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
13
+ const pins = items(record.evidencePins);
14
+ const actions = items(record.actions);
15
+ const reviews = items(object(record.observedEffects).reviews);
16
+ const closures = items(object(record.closures).records);
17
+ const questions = [...items(record.assertions), ...reviews].map(item => object(item.recheck));
18
+ const events = actions.flatMap(action => items(action.history));
19
+ if (pins.some(pin => pin.responseObject !== undefined || pin.infrastructureObservation !== undefined)
20
+ || actions.some(action => action.responseObjects !== undefined)
21
+ || events.some(event => event.responseObjects !== undefined || event.objectOutcome !== undefined)
22
+ || [...reviews, ...closures, ...questions].some(item => item.responseObject !== undefined || item.objectOutcome !== undefined)
23
+ || [...items(record.evidenceHistory), object(record.evidence)].some(snapshot => snapshot.hasExternalPasswordForm !== undefined)) {
24
+ throw new TypeError('Object response and password-form attribution fields require Case schema 18; historical evidence was not reinterpreted.');
25
+ }
26
+ }
5
27
  if (record.evidenceLinks !== undefined && declared && sourceVersion < EVIDENCE_FOLLOW_UP_CASE_SCHEMA_VERSION) {
6
28
  throw new TypeError('Evidence relationships require the current Case schema.');
7
29
  }
@@ -8,15 +8,19 @@
8
8
  // contains raw RDAP/WHOIS material. This module deliberately excludes all raw
9
9
  // registry/web responses, contacts, cookies, screenshots, and authentication
10
10
  // data. Reports contain only the normalized case record.
11
- import { caseEvidenceTimeline, compareCaseEvidence, currentCaseEvidence, publishedCaseEvidenceTimelineForVerification } from "./case-evidence-model.mjs";
11
+ import { caseEvidenceTimeline, caseEvidenceWithoutCollectionReceipts, compareCaseEvidence, currentCaseEvidence, publishedCaseEvidenceTimelineForVerification } from "./case-evidence-model.mjs";
12
12
  import { httpSecurityHeaderLabel } from "./http-summary.mjs";
13
13
  import { webCollectionScoreLimitation } from "../evidence/collection-quality.mjs";
14
14
  import { analystInteroperabilityTags } from "../analysis/analyst-taxonomy.mjs";
15
15
  import { caseEvidenceLinkIssues } from "./case-evidence-links.mjs";
16
- import { caseTypeSummary } from "./case-workflow-metadata.mjs";
16
+ import { caseTypeSummary } from "./case-type-presentation.mjs";
17
+ import { caseClosureHistoryQualification } from "./case-response-outcomes.mjs";
17
18
  import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../analysis/portable-generator.mjs";
18
19
  import { buildCaseResponseLifecycleSummary, CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.mjs";
19
20
  import { normalizeCaseBrandProfileIds } from "./case-brand-profile-references.mjs";
21
+ import { EVIDENCE_FOLLOW_UP_CASE_REPORT_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
22
+ import { OBJECT_RESPONSE_CASE_REPORT_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
23
+ import { CASE_DELIVERY_RECEIPT_LIMITATION } from "./case-packet-correction.mjs";
20
24
  import { CASE_RECHECK_CONDITIONS } from "./case-recheck-model.mjs";
21
25
  import { escapeCaseMarkdownInline as escapeMarkdownInline } from "./case-markdown.mjs";
22
26
  import { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION, LATEST_PUBLIC_CASE_REPORT_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_REPORT_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
@@ -145,6 +149,7 @@ function pickKnownSnapshotFields(snapshot) {
145
149
  reusesOfficialAssets: snapshot.reusesOfficialAssets,
146
150
  hasPasswordField: snapshot.hasPasswordField,
147
151
  hasExternalFormAction: snapshot.hasExternalFormAction,
152
+ ...(snapshot.hasExternalPasswordForm === undefined ? {} : { hasExternalPasswordForm: snapshot.hasExternalPasswordForm }),
148
153
  phishingLanguageMatch: snapshot.phishingLanguageMatch,
149
154
  privacyProtected: snapshot.privacyProtected ?? null,
150
155
  idnReferenceMatch: snapshot.idnReferenceMatch ?? null,
@@ -173,9 +178,10 @@ export function buildCaseReport(caseRecord, options = {}) {
173
178
  const now = generatedAt || new Date().toISOString();
174
179
  const generator = buildPortableGeneratorMetadata(options.applicationVersion);
175
180
  // --- Build JSON report ---
176
- const timeline = caseEvidenceTimeline(caseRecord.evidenceHistory);
181
+ const sharedEvidence = { ...caseRecord, evidenceHistory: caseEvidenceWithoutCollectionReceipts(caseRecord) };
182
+ const timeline = caseEvidenceTimeline(sharedEvidence.evidenceHistory);
177
183
  const timelineEntries = projectReportTimeline(timeline);
178
- const selection = currentCaseEvidence(caseRecord);
184
+ const selection = currentCaseEvidence(sharedEvidence);
179
185
  const latest = selection.snapshot;
180
186
  const currentAssessment = latest ? pickKnownSnapshotFields(latest) : null;
181
187
  const responseLifecycle = buildCaseResponseLifecycleSummary(caseRecord);
@@ -279,6 +285,10 @@ export function buildCaseReportVerificationProjection(caseRecord, options, schem
279
285
  const generated = buildCaseReport(caseRecord, options).json;
280
286
  if (schemaVersion === CASE_REPORT_SCHEMA_VERSION)
281
287
  return generated;
288
+ if (schemaVersion === OBJECT_RESPONSE_CASE_REPORT_SCHEMA_VERSION)
289
+ return { ...generated, schemaVersion };
290
+ if (schemaVersion === EVIDENCE_FOLLOW_UP_CASE_REPORT_SCHEMA_VERSION)
291
+ return { ...generated, schemaVersion };
282
292
  if (caseRecord.evidenceHistory.some(snapshot => snapshot.webCollectionQuality !== undefined)) {
283
293
  throw new TypeError('Published report formats cannot declare newer collection-quality fields.');
284
294
  }
@@ -499,6 +509,8 @@ function buildMarkdown(report, includeAttribution) {
499
509
  for (const pin of response.evidencePins) {
500
510
  lines.push(`- **${escapeMarkdownInline(pin.label)}:** ${escapeMarkdownInline(pin.value)}`);
501
511
  lines.push(` Source: ${escapeMarkdownInline(pin.source)}; observed ${escapeMarkdownInline(pin.observedAt ?? 'Time unavailable')}; completeness ${escapeMarkdownInline(pin.completeness)}.`);
512
+ if (pin.responseObject)
513
+ lines.push(` Exact object: ${escapeMarkdownInline(pin.responseObject.kind)} · ${escapeMarkdownInline(pin.responseObject.identifier)}`);
502
514
  if (pin.limitations.length)
503
515
  lines.push(` Limitations: ${escapeMarkdownInline(pin.limitations.join('; '))}`);
504
516
  }
@@ -587,6 +599,7 @@ function buildMarkdown(report, includeAttribution) {
587
599
  for (const action of response.actions) {
588
600
  lines.push(`- **${escapeMarkdownInline(action.type.replaceAll('_', ' '))}:** ${escapeMarkdownInline(action.recipient)} (${escapeMarkdownInline(action.state)})`);
589
601
  lines.push(` Contact source: ${escapeMarkdownInline(action.contactSource)}; updated ${escapeMarkdownInline(action.updatedAt)}.`);
602
+ lines.push(` Object binding: ${action.responseObjects?.length ? action.responseObjects.map(object => `${escapeMarkdownInline(object.kind)} · ${escapeMarkdownInline(object.identifier)}`).join('; ') : 'Unknown; historical or unbound actions do not establish exact-object coverage.'}`);
590
603
  if (action.routeObservedAt)
591
604
  lines.push(` Route reviewed: ${escapeMarkdownInline(action.routeObservedAt)}`);
592
605
  if (action.routeReviewAfter)
@@ -595,6 +608,8 @@ function buildMarkdown(report, includeAttribution) {
595
608
  lines.push(` Originating action: ${escapeMarkdownInline(action.originActionId)}`);
596
609
  if (action.amendment)
597
610
  lines.push(` Amendment of submitted packet SHA-256: ${action.amendment.packetDigestSha256}; request events: ${action.amendment.requestEventIds.map(escapeMarkdownInline).join(', ')}`);
611
+ if (action.correction)
612
+ lines.push(` ${action.correction.purpose === 'correction' ? 'Correction request' : 'Retraction request'} for delivery event ${escapeMarkdownInline(action.correction.deliveryEventId)}; packet v${action.correction.packetVersion} SHA-256: ${action.correction.packetDigestSha256}`, ` Reason: ${escapeMarkdownInline(action.correction.reason)}`, ` Previous statement: ${escapeMarkdownInline(action.correction.previousStatement)}`, ` Corrected statement: ${escapeMarkdownInline(action.correction.correctedStatement || 'Request to retract the previous statement')}`, ` Corrected evidence pins: ${action.correction.evidencePinIds.map(escapeMarkdownInline).join(', ')}`, ' Preparation is not delivery, recipient acceptance, restoration or independent recheck.');
598
613
  for (const event of action.history)
599
614
  if (event.evidenceRequest) {
600
615
  const request = event.evidenceRequest;
@@ -616,8 +631,14 @@ function buildMarkdown(report, includeAttribution) {
616
631
  lines.push(` - ${escapeMarkdownInline(event.occurredAt)} · ${escapeMarkdownInline(event.previousState ?? 'none')} → ${escapeMarkdownInline(event.nextState)} · ${escapeMarkdownInline(event.sourceClass)} · ${event.applied ? 'applied' : 'retained conflict'}`);
617
632
  if (event.providerOutcome)
618
633
  lines.push(` Provider outcome at this time: ${escapeMarkdownInline(event.providerOutcome.replaceAll('_', ' '))}`);
634
+ if (event.responseObjects?.length)
635
+ lines.push(` Objects at this event: ${event.responseObjects.map(object => `${escapeMarkdownInline(object.kind)} · ${escapeMarkdownInline(object.identifier)}`).join('; ')}`);
636
+ if (event.objectOutcome)
637
+ lines.push(` Source-reported object outcome: ${escapeMarkdownInline(event.objectOutcome)}; not independent verification or causation.`);
619
638
  if (event.reference)
620
639
  lines.push(` Event reference: ${escapeMarkdownInline(event.reference)}`);
640
+ if (event.packetReceipt)
641
+ lines.push(` Retained packet v${event.packetReceipt.packetVersion}: ${event.packetReceipt.packetDigestSha256}; recipient ${escapeMarkdownInline(event.packetReceipt.recipient)}; audience ${escapeMarkdownInline(event.packetReceipt.profile)}; generated ${event.packetReceipt.packetGeneratedAt}`, ` ${CASE_DELIVERY_RECEIPT_LIMITATION}`);
621
642
  if (event.evidencePinId)
622
643
  lines.push(` Evidence pin: ${escapeMarkdownInline(event.evidencePinId)}`);
623
644
  if (event.limitations.length)
@@ -667,6 +688,10 @@ function buildMarkdown(report, includeAttribution) {
667
688
  lines.push('- No independent observed-effect review recorded.');
668
689
  for (const review of response.observedEffects.reviews) {
669
690
  lines.push(`- **${escapeMarkdownInline(review.state.replaceAll('_', ' '))}** (${escapeMarkdownInline(review.observedAt)}): ${escapeMarkdownInline(review.source)}; class ${escapeMarkdownInline(review.sourceClass)}; completeness ${escapeMarkdownInline(review.completeness)}.`);
691
+ if (review.responseObject)
692
+ lines.push(` Exact object: ${escapeMarkdownInline(review.responseObject.kind)} · ${escapeMarkdownInline(review.responseObject.identifier)}`);
693
+ if (review.objectOutcome)
694
+ lines.push(` Independently recorded object outcome: ${escapeMarkdownInline(review.objectOutcome)}; point-in-time observation, not report causation or recurrence.`);
670
695
  if (review.recheck) {
671
696
  lines.push(` Question ${escapeMarkdownInline(review.recheck.questionId)}: ${escapeMarkdownInline(review.recheck.question)}`);
672
697
  lines.push(` Recheck target: ${escapeMarkdownInline(review.recheck.targetHostname)}; retained comparison conditions: ${escapeMarkdownInline(review.recheck.conditions)}`);
@@ -689,13 +714,19 @@ function buildMarkdown(report, includeAttribution) {
689
714
  lines.push('- No deliberate analyst closure recorded.');
690
715
  for (const closure of response.closures.records) {
691
716
  lines.push(`- **Closure · ${escapeMarkdownInline(closure.reason.replaceAll('_', ' '))}** (${escapeMarkdownInline(closure.createdAt)}): ${escapeMarkdownInline(closure.summary)}`);
717
+ lines.push(` Scope: ${closure.responseObject ? `${escapeMarkdownInline(closure.responseObject.kind)} · ${escapeMarkdownInline(closure.responseObject.identifier)}; other objects and the Case remain independent.` : 'Whole Case analyst decision.'}`);
692
718
  if (closure.observedEffectReviewId)
693
719
  lines.push(` Independent review: ${escapeMarkdownInline(closure.observedEffectReviewId)}`);
694
720
  if (closure.actionId)
695
721
  lines.push(` Provider action: ${escapeMarkdownInline(closure.actionId)}`);
722
+ const qualification = caseClosureHistoryQualification(closure, response.actions);
723
+ if (qualification)
724
+ lines.push(` History qualification: ${escapeMarkdownInline(qualification)}`);
696
725
  if (closure.limitations.length)
697
726
  lines.push(` Limitations: ${escapeMarkdownInline(closure.limitations.join('; '))}`);
698
727
  }
728
+ if (response.closures.omitted)
729
+ lines.push(`- Closure records not retained: ${response.closures.omitted}. ${escapeMarkdownInline(response.closures.limitations.join('; '))}`);
699
730
  lines.push('');
700
731
  // Analyst notes (only when included)
701
732
  if (report.case.notesIncluded && Array.isArray(report.case.notes) && report.case.notes.length > 0) {
@@ -57,7 +57,7 @@ export function assertEvidenceRequestEvent(request, event) {
57
57
  export function submittedPacketReceipts(action) {
58
58
  return action.history.flatMap(event => {
59
59
  const match = /^response-packet-sha256:([a-f0-9]{64})$/u.exec(event.reference ?? '');
60
- return event.applied && event.previousState === 'authorised' && event.nextState === 'submitted'
60
+ return event.applied && ['authorised', 'submitted'].includes(event.previousState ?? '') && event.nextState === 'submitted'
61
61
  && event.sourceClass === 'analyst' && match
62
62
  ? [{ eventId: event.id, digestSha256: match[1], occurredAt: event.occurredAt }] : [];
63
63
  });
@@ -1,8 +1,10 @@
1
1
  // Append-only response actions: transitions, reconciliation and bounded history.
2
2
  import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertEvidenceRequestTransition, assertPacketAmendment } from "./case-requested-evidence.mjs";
3
+ import { readCaseDeliveryPacketReceipt, readCasePacketCorrection, assertDeliveryPacketReceipt, correctionDelivery } from "./case-packet-correction.mjs";
3
4
  import { MAX_INTAKE_LINKS, MAX_MESSAGE_PARTS } from "../contracts/message-intake.mjs";
4
5
  import { emailRecipient } from "../evidence/email-recipient.mjs";
5
6
  import { responseRouteFreshness } from "./response-route-freshness.mjs";
7
+ import { readCaseResponseObjects, readCaseResponseObjectOutcome, assertCaseObjectOutcome, sameCaseResponseObject } from "./case-response-object.mjs";
6
8
  import { CASE_SCHEMA_VERSION, MAX_CASE_ACTIONS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, } from "../contracts/case-portability.mjs";
7
9
  import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
8
10
  import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_PROVIDER_OUTCOMES, } from "./case-response-records.mjs";
@@ -160,6 +162,12 @@ function normalizeActionEvent(raw, actionId, options = {}) {
160
162
  if (item.evidenceRequest !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
161
163
  throw new Error('Requested evidence requires Case schema 17.');
162
164
  const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
165
+ if (item.packetReceipt !== undefined && options.sourceVersion != null && options.sourceVersion < 19)
166
+ throw new TypeError('Delivery receipts require Case schema 19.');
167
+ const packetReceipt = readCaseDeliveryPacketReceipt(item.packetReceipt);
168
+ assertDeliveryPacketReceipt(packetReceipt, { previousState, nextState, sourceClass, occurredAt,
169
+ reference: typeof item.reference === 'string' ? item.reference : null,
170
+ responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }, actionId);
163
171
  assertEvidenceRequestEvent(evidenceRequest, { previousState, nextState, sourceClass, providerOutcome: item.providerOutcome });
164
172
  const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
165
173
  ? item.providerOutcome
@@ -189,7 +197,10 @@ function normalizeActionEvent(raw, actionId, options = {}) {
189
197
  : null;
190
198
  const eventMaterial = {
191
199
  previousState,
200
+ ...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }),
201
+ ...(item.objectOutcome === undefined ? {} : { objectOutcome: readCaseResponseObjectOutcome(item.objectOutcome, options.sourceVersion) }),
192
202
  ...(evidenceRequest ? { evidenceRequest } : {}),
203
+ ...(packetReceipt ? { packetReceipt } : {}),
193
204
  nextState,
194
205
  occurredAt,
195
206
  sourceClass,
@@ -206,6 +217,16 @@ function normalizeActionEvent(raw, actionId, options = {}) {
206
217
  ? item.originActionId
207
218
  : null,
208
219
  };
220
+ if (eventMaterial.objectOutcome) {
221
+ if (!['submitted', 'acknowledged', 'terminal'].includes(nextState) || previousState === 'authorised')
222
+ throw new TypeError('Object outcomes require a separate attributed event after submission.');
223
+ if (sourceClass !== 'provider' && sourceClass !== 'analyst')
224
+ throw new TypeError('Object outcomes require an attributed provider or analyst event.');
225
+ if (!eventMaterial.responseObjects?.length)
226
+ throw new TypeError('Select the objects concerned by this provider outcome; no complete-action resolution is inferred.');
227
+ for (const object of eventMaterial.responseObjects)
228
+ assertCaseObjectOutcome(eventMaterial.objectOutcome, object);
229
+ }
209
230
  return {
210
231
  id: safeId(item.id, 'action-event', { actionId, ...eventMaterial }),
211
232
  ...eventMaterial,
@@ -373,6 +394,11 @@ function normalizeAction(raw, fallback, options = {}) {
373
394
  if (item.amendment !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
374
395
  throw new Error('Packet amendments require Case schema 17.');
375
396
  const amendment = readCasePacketAmendment(item.amendment);
397
+ if (item.correction !== undefined && options.sourceVersion != null && options.sourceVersion < 19)
398
+ throw new TypeError('Packet corrections require Case schema 19.');
399
+ const correction = readCasePacketCorrection(item.correction);
400
+ if (correction && amendment)
401
+ throw new TypeError('Self-initiated correction and provider-request amendment are separate purposes.');
376
402
  const applied = history.history.filter((event) => event.applied);
377
403
  const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
378
404
  const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
@@ -387,7 +413,9 @@ function normalizeAction(raw, fallback, options = {}) {
387
413
  : null;
388
414
  return {
389
415
  id: actionId,
416
+ ...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }),
390
417
  ...(amendment ? { amendment } : {}),
418
+ ...(correction ? { correction } : {}),
391
419
  type: legacyPlatformReview
392
420
  ? 'platform_report'
393
421
  : typeof item.type === 'string' && ACTION_TYPES.has(item.type)
@@ -575,9 +603,14 @@ export function appendCaseAction(current, raw, now) {
575
603
  const amendment = readCasePacketAmendment(item.amendment);
576
604
  if (amendment)
577
605
  assertPacketAmendment(current, originActionId, amendment);
606
+ const correction = readCasePacketCorrection(item.correction);
607
+ if (correction)
608
+ correctionDelivery(current, { originActionId, correction,
609
+ recipient: text(item.recipient, MAX_RESPONSE_RECIPIENT_LENGTH), responseObjects: readCaseResponseObjects(item.responseObjects) });
578
610
  const history = [{
579
611
  id: freshId('action-event'),
580
612
  previousState: null,
613
+ ...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects) }),
581
614
  nextState: 'drafting',
582
615
  occurredAt: now,
583
616
  sourceClass: 'analyst',
@@ -602,11 +635,22 @@ export function appendCaseAction(current, raw, now) {
602
635
  throw new Error('An action requires a recipient or internal owner.');
603
636
  return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
604
637
  }
638
+ export function providerOutcomeRequiresObjectScope(outcome) {
639
+ return outcome === 'provider_reports_resolved' || outcome === 'partially_remediated';
640
+ }
605
641
  export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
606
642
  const action = current.find((item) => item.id === actionId);
607
643
  if (!action)
608
644
  throw new Error('That case action no longer exists.');
609
645
  const item = record(raw);
646
+ const selectedObjects = readCaseResponseObjects(item.responseObjects);
647
+ const outcome = readCaseResponseObjectOutcome(item.objectOutcome);
648
+ if (selectedObjects?.some(object => !action.responseObjects?.some(bound => sameCaseResponseObject(bound, object))))
649
+ throw new TypeError('A transition may concern only objects explicitly bound to its action.');
650
+ if (outcome && !selectedObjects?.length)
651
+ throw new TypeError('Explicitly select the affected objects for this outcome; it is not applied to every action object.');
652
+ if (action.responseObjects?.length && providerOutcomeRequiresObjectScope(item.providerOutcome) && !selectedObjects?.length)
653
+ throw new TypeError('Explicitly select the objects covered by this provider-reported outcome; other action objects remain unchecked.');
610
654
  const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
611
655
  ? item.nextState
612
656
  : null;
@@ -637,9 +681,18 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
637
681
  if (action.amendment && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)) {
638
682
  assertPacketAmendment(current, action.originActionId, action.amendment);
639
683
  }
684
+ if (action.correction && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState))
685
+ correctionDelivery(current, action);
686
+ const packetReceipt = readCaseDeliveryPacketReceipt(item.packetReceipt);
687
+ if (packetReceipt && (packetReceipt.recipient !== action.recipient
688
+ || JSON.stringify(packetReceipt.responseObjects) !== JSON.stringify(action.responseObjects ?? [])))
689
+ throw new TypeError('The delivered packet receipt no longer matches this action recipient or scope.');
690
+ if (action.correction && nextState === 'submitted' && !packetReceipt)
691
+ throw new TypeError('Record the separately prepared correction packet receipt only after actual delivery.');
640
692
  const occurredAt = optionalIso(item.occurredAt) ?? now;
641
693
  const event = normalizeActionEvent({
642
694
  ...item,
695
+ ...(selectedObjects !== undefined ? { responseObjects: selectedObjects } : action.responseObjects === undefined ? {} : { responseObjects: action.responseObjects }),
643
696
  id: freshId('action-event'),
644
697
  previousState: action.state,
645
698
  nextState,
@@ -658,7 +711,7 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
658
711
  return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
659
712
  }
660
713
  const ACTION_REVIEW_MATERIAL_FIELDS = [
661
- 'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId', 'amendment',
714
+ 'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId', 'amendment', 'correction', 'responseObjects',
662
715
  ];
663
716
  export function updateCaseAction(current, raw, now, validPinIds) {
664
717
  const patch = record(raw);
@@ -689,6 +742,8 @@ export function updateCaseAction(current, raw, now, validPinIds) {
689
742
  .some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
690
743
  if (materialChanged && updated.amendment)
691
744
  assertPacketAmendment(current, updated.originActionId, updated.amendment);
745
+ if (materialChanged && updated.correction)
746
+ correctionDelivery(current, updated);
692
747
  if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
693
748
  throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
694
749
  }
@@ -702,6 +757,7 @@ export function updateCaseAction(current, raw, now, validPinIds) {
702
757
  occurredAt: now,
703
758
  sourceClass: 'browser_local',
704
759
  provenance: 'material_action_change',
760
+ ...(updated.responseObjects === undefined ? {} : { responseObjects: updated.responseObjects }),
705
761
  reference: null,
706
762
  evidencePinId: null,
707
763
  limitations: ['Material action inputs changed after review; prior readiness, review, or authorisation no longer applies.'],
@@ -7,9 +7,12 @@ import { CASE_ASSERTION_EXTERNAL_ENTITY_TYPES, CASE_ASSERTION_EXTERNAL_FORMATS,
7
7
  import { COMPLETENESS, SAFE_ID_RE, compareCodeUnits, freshId, iso, limitations, optionalIso, record, safeId, text, uniqueIds, } from "./case-response-values.mjs";
8
8
  import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
9
9
  import { readCaseRecheckContext } from "./case-recheck-model.mjs";
10
+ import { readCaseResponseObject } from "./case-response-object.mjs";
11
+ import { normalizeInfrastructureObservation } from "../investigation/infrastructure-observation.mjs";
10
12
  export * from "./case-response-records.mjs";
11
13
  export * from "./case-response-actions.mjs";
12
14
  export * from "./case-response-outcomes.mjs";
15
+ export * from "./case-response-object.mjs";
13
16
  export { MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_CLOSURES, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, } from "../contracts/case-portability.mjs";
14
17
  const TRANSITION_EXPECTATIONS = new Set(CASE_TRANSITION_EXPECTATIONS);
15
18
  const DECISION_CONFIDENCE_LEVELS = new Set(CASE_DECISION_CONFIDENCE_LEVELS);
@@ -108,6 +111,8 @@ function normalizeEvidenceRelations(value, legacyIds, validIds) {
108
111
  }
109
112
  function normalizePin(raw, fallback, options = {}) {
110
113
  const item = record(raw);
114
+ if (item.infrastructureObservation !== undefined && options.sourceVersion != null && options.sourceVersion < 18)
115
+ throw new TypeError('Infrastructure observations require Case schema 18 or later.');
111
116
  const label = text(item.label, MAX_RESPONSE_LABEL_LENGTH);
112
117
  const value = text(item.value, MAX_RESPONSE_VALUE_LENGTH);
113
118
  if (!label || !value)
@@ -141,6 +146,8 @@ function normalizePin(raw, fallback, options = {}) {
141
146
  : null,
142
147
  limitations: limitations(item.limitations),
143
148
  createdAt,
149
+ ...(item.responseObject === undefined ? {} : { responseObject: readCaseResponseObject(item.responseObject, options.sourceVersion) }),
150
+ ...(item.infrastructureObservation === undefined ? {} : { infrastructureObservation: normalizeInfrastructureObservation(item.infrastructureObservation) }),
144
151
  };
145
152
  normalized.certificateObservation = options.allowCertificateObservation === false
146
153
  ? null
@@ -0,0 +1,119 @@
1
+ // Explicit object scope, not ownership, collection or proof of remediation.
2
+ import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
3
+ import { OBJECT_RESPONSE_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
4
+ import { array, enumeration, exact, text } from "../evidence/artifact-structure.mjs";
5
+ export const CASE_RESPONSE_OBJECT_KINDS = ['domain', 'hostname', 'page', 'redirect', 'advertisement', 'social_account', 'social_post', 'messaging', 'storefront', 'app', 'other'];
6
+ export const CASE_RESPONSE_OBJECT_OUTCOMES = ['removed', 'restricted', 'suspended', 'delisted', 'warning', 'transferred', 'restored', 'disputed'];
7
+ export const MAX_CASE_RESPONSE_OBJECT_IDENTIFIER = 1_979;
8
+ export const MAX_CASE_RESPONSE_OBJECTS = 20;
9
+ export function readCaseResponseObjects(value, sourceVersion) {
10
+ if (value === undefined)
11
+ return undefined;
12
+ if (sourceVersion != null && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
13
+ throw new TypeError('Object-specific response scope requires Case schema 18 or later.');
14
+ const objects = array(value, 'Response objects', MAX_CASE_RESPONSE_OBJECTS).map(item => readCaseResponseObject(item, sourceVersion));
15
+ const keyed = objects.map(object => [JSON.stringify(object), object]);
16
+ if (new Set(keyed.map(([key]) => key)).size !== keyed.length)
17
+ throw new TypeError('Response objects must be unique.');
18
+ return Object.freeze(keyed.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([, object]) => object));
19
+ }
20
+ /** Historical absence stays absent; a declared historical format cannot carry new fields. */
21
+ export function readCaseResponseObject(value, sourceVersion) {
22
+ if (value === undefined)
23
+ return undefined;
24
+ if (sourceVersion != null && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
25
+ throw new TypeError('Object-specific response scope requires Case schema 18 or later.');
26
+ const item = exact(value, ['kind', 'identifier', 'incidentTargetId'], 'Response object');
27
+ const kind = enumeration(item.kind, CASE_RESPONSE_OBJECT_KINDS, 'Response object kind');
28
+ const identifier = text(item.identifier, 'Response object identifier', MAX_CASE_RESPONSE_OBJECT_IDENTIFIER);
29
+ if (!identifier || identifier.trim() !== identifier || /[\u0000-\u001f\u007f]/u.test(identifier))
30
+ throw new TypeError('Response object identifier must be a bounded single-line value.');
31
+ const incidentTargetId = item.incidentTargetId === null ? null : text(item.incidentTargetId, 'Incident object identity', 64);
32
+ if (incidentTargetId !== null && !/^[A-Za-z0-9_-]{1,64}$/u.test(incidentTargetId))
33
+ throw new TypeError('Incident object identity is invalid.');
34
+ if (kind === 'domain' || kind === 'hostname') {
35
+ if (identifier !== identifier.toLowerCase() || !isValidAsciiHostname(identifier) || incidentTargetId !== null)
36
+ throw new TypeError('Domain and hostname scope require a normalised hostname and no incident-link identity.');
37
+ }
38
+ else {
39
+ let url;
40
+ try {
41
+ url = new URL(identifier);
42
+ }
43
+ catch {
44
+ throw new TypeError('Select an exact retained HTTP(S) incident link for this object.');
45
+ }
46
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.toString() !== identifier || !incidentTargetId)
47
+ throw new TypeError('Select an exact retained HTTP(S) incident link without credentials for this object.');
48
+ }
49
+ return Object.freeze({ kind, identifier, incidentTargetId });
50
+ }
51
+ export function readCaseResponseObjectOutcome(value, sourceVersion) {
52
+ if (value === undefined)
53
+ return undefined;
54
+ if (sourceVersion != null && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
55
+ throw new TypeError('Object-specific outcomes require Case schema 18 or later.');
56
+ return enumeration(value, CASE_RESPONSE_OBJECT_OUTCOMES, 'Response object outcome');
57
+ }
58
+ export function sameCaseResponseObject(left, right) {
59
+ return left !== undefined && right !== undefined && left.kind === right.kind && left.identifier === right.identifier && left.incidentTargetId === right.incidentTargetId;
60
+ }
61
+ /** Explicit retained snapshots permit follow-up without retargeting edited links. */
62
+ export function retainedCaseResponseObjects(record) {
63
+ const objects = [...(record.actions ?? []).flatMap(action => [...action.responseObjects ?? [], ...action.history.flatMap(event => event.responseObjects ?? [])]),
64
+ ...(record.observedEffects?.reviews ?? []).flatMap(review => review.responseObject ? [review.responseObject] : []),
65
+ ...(record.evidencePins ?? []).flatMap(pin => pin.responseObject ? [pin.responseObject] : []),
66
+ ...(record.closures?.records ?? []).flatMap(closure => closure.responseObject ? [closure.responseObject] : []),
67
+ ...(record.assertions ?? []).flatMap(assertion => assertion.recheck?.responseObject ? [assertion.recheck.responseObject] : [])];
68
+ return [...new Map(objects.map(object => [JSON.stringify(object), object])).values()];
69
+ }
70
+ /** New identities require current metadata; exact historical identities remain usable. */
71
+ export function assertCaseResponseObject(object, record) {
72
+ if (!object)
73
+ return;
74
+ if (object.kind === 'domain') {
75
+ if (object.identifier !== record.domain)
76
+ throw new TypeError('The response domain must match this Case.');
77
+ }
78
+ else if (object.kind === 'hostname') {
79
+ if (object.identifier !== record.domain && !object.identifier.endsWith(`.${record.domain}`))
80
+ throw new TypeError('The response hostname must belong to this Case domain.');
81
+ }
82
+ else if (!record.workflowMetadata?.incidentTargets.some(target => target.id === object.incidentTargetId && target.url === object.identifier)
83
+ && !retainedCaseResponseObjects(record).some(retained => sameCaseResponseObject(retained, object))) {
84
+ throw new TypeError('The selected exact incident object changed or is not retained in this Case. Refresh and review it before saving.');
85
+ }
86
+ }
87
+ export function assertCaseObjectOutcome(outcome, object) {
88
+ if (!outcome)
89
+ return;
90
+ if (!object)
91
+ throw new TypeError('A typed object outcome requires explicit object scope.');
92
+ if (outcome === 'suspended' && object.kind !== 'domain')
93
+ throw new TypeError('Domain suspension requires domain scope.');
94
+ if (outcome === 'restricted' && object.kind !== 'social_account' && object.kind !== 'app' && object.kind !== 'other')
95
+ throw new TypeError('Account restriction requires account, app or explicitly other scope.');
96
+ }
97
+ export function caseResponseObjectChoices(record) {
98
+ const objects = [{ kind: 'domain', identifier: record.domain, incidentTargetId: null }];
99
+ const hosts = new Set([...(record.evidencePins ?? []).map(pin => pin.observationHostname), ...(record.evidenceHistory ?? []).map(snapshot => snapshot.inputHostname)].filter((value) => Boolean(value)));
100
+ for (const identifier of hosts)
101
+ if (identifier === record.domain || identifier.endsWith(`.${record.domain}`))
102
+ objects.push({ kind: 'hostname', identifier, incidentTargetId: null });
103
+ for (const target of record.workflowMetadata?.incidentTargets ?? [])
104
+ for (const kind of CASE_RESPONSE_OBJECT_KINDS.filter(kind => kind !== 'domain' && kind !== 'hostname'))
105
+ objects.push({ kind, identifier: target.url, incidentTargetId: target.id });
106
+ objects.push(...retainedCaseResponseObjects(record));
107
+ return [...new Map(objects.map(object => [JSON.stringify(object), object])).values()].map(object => {
108
+ const historical = Boolean(object.incidentTargetId && !record.workflowMetadata?.incidentTargets.some(target => target.id === object.incidentTargetId && target.url === object.identifier));
109
+ return { value: JSON.stringify(object), label: `${object.kind.replaceAll('_', ' ')} · ${object.identifier}${historical ? ' · historical / no current incident link' : ''}`, object };
110
+ });
111
+ }
112
+ export function selectedCaseResponseObject(record, value) {
113
+ if (!value)
114
+ return undefined;
115
+ const object = caseResponseObjectChoices(record).find(choice => choice.value === value)?.object;
116
+ if (!object)
117
+ throw new TypeError('The selected incident object changed. Refresh and review it before saving.');
118
+ return readCaseResponseObject(object);
119
+ }