@slicedearth/whoisleuth-cli 2.5.1 → 2.6.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 (122) hide show
  1. package/DISCLOSURE +1 -1
  2. package/cli/artifact-validation/case-response.mjs +51 -8
  3. package/cli/artifact-validation/lookup-evidence.mjs +15 -1
  4. package/cli/artifact-validation/signed-review.mjs +2 -51
  5. package/cli/artifact-verify.mjs +9 -2
  6. package/cli/bulk-command-runner.mjs +4 -6
  7. package/cli/case-command.mjs +79 -3
  8. package/cli/case-pack.mjs +7 -2
  9. package/cli/command-definition.mjs +2 -1
  10. package/cli/discovery-scan-command-runner.mjs +4 -6
  11. package/cli/domain-feed.mjs +61 -0
  12. package/cli/formatters/json.mjs +1 -13
  13. package/cli/indicator-set-command.mjs +1 -2
  14. package/cli/intake-command.mjs +43 -3
  15. package/cli/investigation-artifacts.mjs +2 -3
  16. package/cli/investigation-package-review.mjs +2 -0
  17. package/cli/local-mmdb-review.mjs +27 -4
  18. package/cli/lookup-command-runner.mjs +1 -0
  19. package/cli/offline-evidence-review.mjs +51 -5
  20. package/cli/review-arguments.mjs +3 -2
  21. package/cli/review-command-definitions.mjs +14 -8
  22. package/cli/review-command-runner.mjs +6 -3
  23. package/cli/risk-calibration.mjs +6 -4
  24. package/cli/selected-input-worker.mjs +12 -2
  25. package/cli/support-command-runner.mjs +3 -3
  26. package/cli/watchlist-review.mjs +43 -0
  27. package/cli/workflow-arguments.mjs +14 -0
  28. package/cli/workflow-command-definitions.mjs +26 -1
  29. package/cli/workflow-command-runner.mjs +32 -0
  30. package/docs/cli.md +99 -2
  31. package/docs/contextual-reviews.md +28 -5
  32. package/docs/infrastructure-observations.md +126 -0
  33. package/docs/offline-intake-context.md +213 -0
  34. package/lib/credential-form-attribution.mjs +136 -0
  35. package/lib/credential-surface-profile.mjs +6 -2
  36. package/lib/dns-delegation-health.mjs +23 -7
  37. package/lib/dns-intelligence.mjs +18 -4
  38. package/lib/domain-posture-analysis.mjs +53 -2
  39. package/lib/domain-posture-parsers.mjs +9 -0
  40. package/lib/domain-posture.mjs +29 -10
  41. package/lib/evidence-export-privacy.mjs +5 -2
  42. package/lib/evidence-report.mjs +1 -0
  43. package/lib/favicon.mjs +13 -4
  44. package/lib/html-signals.mjs +6 -0
  45. package/lib/lookup-child-profile-contract.mjs +28 -11
  46. package/lib/lookup-response-contract.mjs +1 -0
  47. package/lib/operation-budget.mjs +4 -0
  48. package/lib/service-binding-dns.mjs +19 -2
  49. package/lib/sslbl-certificates.generated.mjs +138 -137
  50. package/lib/static-html-analysis.mjs +32 -4
  51. package/lib/technology-evidence-role.mjs +1 -51
  52. package/lib/tls-intelligence.mjs +30 -5
  53. package/lib/website-enrichment.mjs +39 -23
  54. package/lib/whois-chain.mjs +12 -3
  55. package/lib/whois-common-formats.mjs +3 -1
  56. package/lib/whois-contacts.mjs +13 -2
  57. package/package.json +7 -5
  58. package/packages/analysis/risk-scoring.mjs +12 -9
  59. package/packages/cases/case-containment-handoff.mjs +195 -0
  60. package/packages/cases/case-evidence-links.mjs +16 -0
  61. package/packages/cases/case-evidence-model.mjs +14 -3
  62. package/packages/cases/case-object-coverage.mjs +33 -0
  63. package/packages/cases/case-packet-correction.mjs +106 -0
  64. package/packages/cases/case-recheck-model.mjs +35 -5
  65. package/packages/cases/case-record-operations.mjs +74 -9
  66. package/packages/cases/case-record-projection.mjs +15 -3
  67. package/packages/cases/case-record-version-input.mjs +23 -1
  68. package/packages/cases/case-report.mjs +30 -0
  69. package/packages/cases/case-requested-evidence.mjs +1 -1
  70. package/packages/cases/case-response-actions.mjs +57 -1
  71. package/packages/cases/case-response-model.mjs +7 -0
  72. package/packages/cases/case-response-object.mjs +119 -0
  73. package/packages/cases/case-response-outcomes.mjs +81 -14
  74. package/packages/cases/case-response-packet-render.mjs +22 -3
  75. package/packages/cases/case-response-packet.mjs +30 -2
  76. package/packages/cases/case-response-review-inputs.mjs +48 -7
  77. package/packages/cases/case-workflow-metadata.mjs +1 -9
  78. package/packages/contracts/analyst-interchange.mjs +7 -5
  79. package/packages/contracts/candidate-watch-review.mjs +54 -0
  80. package/packages/contracts/case-portability.mjs +260 -42
  81. package/packages/contracts/cli-command-semantics.mjs +3 -0
  82. package/packages/contracts/context-review.mjs +2 -1
  83. package/packages/contracts/external-observation-interchange.mjs +96 -28
  84. package/packages/contracts/lookup-evidence.mjs +3 -2
  85. package/packages/contracts/message-intake.mjs +7 -1
  86. package/packages/contracts/risk-calibration.mjs +44 -29
  87. package/packages/contracts/schema-lifecycle.mjs +3 -1
  88. package/packages/contracts/web-capture.mjs +1 -1
  89. package/packages/contracts/workspace-portability.mjs +86 -16
  90. package/packages/evidence/collection-quality.mjs +1 -1
  91. package/packages/evidence/credential-form-attribution.mjs +60 -0
  92. package/packages/evidence/image-regions.mjs +15 -0
  93. package/packages/evidence/record-identity.mjs +60 -16
  94. package/packages/evidence/technology-evidence-role.mjs +51 -0
  95. package/packages/interchange/external-findings-import.mjs +32 -17
  96. package/packages/interchange/json-output.mjs +14 -0
  97. package/packages/interchange/managed-indicator-export.mjs +3 -2
  98. package/packages/interchange/managed-indicator-set.mjs +13 -0
  99. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  100. package/packages/investigation/context-review.mjs +3 -3
  101. package/packages/investigation/document-intake.mjs +9 -3
  102. package/packages/investigation/domain-history-review.mjs +43 -11
  103. package/packages/investigation/domain-transition-review.mjs +2 -0
  104. package/packages/investigation/infrastructure-observation.mjs +234 -0
  105. package/packages/investigation/intake-context.mjs +284 -0
  106. package/packages/investigation/intake-indicators.mjs +116 -0
  107. package/packages/investigation/intake-phones.mjs +155 -0
  108. package/packages/investigation/intake-report.mjs +2 -1
  109. package/packages/investigation/investigation-manifest.mjs +33 -7
  110. package/packages/investigation/link-intake.mjs +38 -8
  111. package/packages/investigation/message-intake.mjs +55 -15
  112. package/packages/monitoring/domain-feed.mjs +321 -0
  113. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  114. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  115. package/packages/workspace/brand-profile-model.mjs +46 -2
  116. package/packages/workspace/bulk-session-model.mjs +4 -0
  117. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  118. package/packages/workspace/detection-rule-model.mjs +10 -2
  119. package/packages/workspace/shortlist-model.mjs +10 -2
  120. package/packages/workspace/watchlist-history.mjs +31 -4
  121. package/packages/workspace/watchlist-store.mjs +136 -7
  122. package/third-party-notices.txt +29 -29
@@ -3,6 +3,8 @@
3
3
  import { normalizeCaseObjective, parseIncidentUrlContext, } from "./case-incident-context.mjs";
4
4
  import { selectExistingCase } from "./case-selection.mjs";
5
5
  import { emptyCaseWorkflowMetadata, updateCaseWorkflowMetadata } from "./case-workflow-metadata.mjs";
6
+ import { assertCaseResponseObject, readCaseResponseObject, readCaseResponseObjects, sameCaseResponseObject } from "./case-response-object.mjs";
7
+ import { correctionDelivery, readCasePacketCorrection, readCaseDeliveryPacketReceipt } from "./case-packet-correction.mjs";
6
8
  import { readCaseWorkflowFields } from "./case-workflow-migration.mjs";
7
9
  import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseObservedEffectReview, appendCaseSighting, buildCaseClosureLinkContext, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.mjs";
8
10
  import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
@@ -10,7 +12,7 @@ import { MAX_CASE_OBJECTIVE_LENGTH, MAX_RESPONSE_VALUE_LENGTH } from "../contrac
10
12
  import { caseRecordVersionInput } from "./case-record-version-input.mjs";
11
13
  import { readCaseAttachments } from "./case-attachment-model.mjs";
12
14
  import { readCaseEvidenceLinks, appendCaseEvidenceLink, withdrawCaseEvidenceLink } from "./case-evidence-links.mjs";
13
- import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
15
+ import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, assertCaseObjectObservationOutcome, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
14
16
  import { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, } from "./case-record-decisions.mjs";
15
17
  import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, caseTimestampOrNull, deterministicId, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.mjs";
16
18
  import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
@@ -66,6 +68,11 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
66
68
  const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt, timestampOptions);
67
69
  const pinIds = new Set(evidencePins.map((item) => item.id));
68
70
  const actions = normalizeCaseActions(record.actions, updatedAt, { ...timestampOptions, validEvidencePinIds: pinIds });
71
+ for (const action of actions)
72
+ for (const event of action.history)
73
+ if (event.packetReceipt
74
+ && (event.packetReceipt.caseId !== record.id || event.packetReceipt.target !== domain))
75
+ throw new TypeError('The packet receipt belongs to a different Case or target.');
69
76
  const assertions = normalizeCaseAssertions(record.assertions, updatedAt, pinIds, timestampOptions);
70
77
  const sightings = normalizeCaseSightings(record.sightings, updatedAt, pinIds, timestampOptions);
71
78
  const sightingIds = new Set(sightings.map((item) => item.id));
@@ -80,7 +87,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
80
87
  domain,
81
88
  title: normalizeCaseObjective(record.title),
82
89
  status: caseStatusRequiresClosure(normalizedStatus)
83
- && closures.records.length === 0 && !closures.preV13HistoryUnavailable
90
+ && !closures.records.some(closure => closure.responseObject === undefined) && !closures.preV13HistoryUnavailable
84
91
  ? 'reviewing'
85
92
  : normalizedStatus,
86
93
  disposition: normalizeDisposition(record.disposition),
@@ -117,6 +124,13 @@ export function createCase(input, nowIso) {
117
124
  throw new Error('A valid domain is required to open a case.');
118
125
  const noteBody = normalizeNoteBody(input.note);
119
126
  const source = normalizeSource(input.source);
127
+ const workflowMetadata = updateCaseWorkflowMetadata(emptyCaseWorkflowMetadata(), input, domain, now);
128
+ const scopedCase = { domain, workflowMetadata };
129
+ for (const raw of [input.evidencePin, ...(Array.isArray(input.evidencePins) ? input.evidencePins : []), input.observedEffectReview, input.closure])
130
+ assertCaseResponseObject(readCaseResponseObject(objectRecord(raw).responseObject), scopedCase);
131
+ for (const object of readCaseResponseObjects(objectRecord(input.action).responseObjects) ?? [])
132
+ assertCaseResponseObject(object, scopedCase);
133
+ assertCaseResponseObject(readCaseResponseObject(objectRecord(objectRecord(input.assertion).recheck).responseObject), scopedCase);
120
134
  const evidencePins = input.evidencePins !== undefined
121
135
  ? appendCaseEvidencePins([], input.evidencePins, now)
122
136
  : input.evidencePin !== undefined
@@ -137,13 +151,19 @@ export function createCase(input, nowIso) {
137
151
  ? appendCaseObservedEffectReview(normalizeCaseObservedEffectHistory(undefined, now), input.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)))
138
152
  : normalizeCaseObservedEffectHistory(undefined, now);
139
153
  for (const review of observedEffects.reviews) {
154
+ const pin = evidencePins.find(item => item.id === review.evidencePinId);
155
+ if (review.responseObject && pin && !sameCaseResponseObject(review.responseObject, pin.responseObject))
156
+ throw new TypeError('The selected observation evidence does not explicitly concern this exact object.');
157
+ if (review.responseObject && review.state === 'not_reproduced' && !review.recheck)
158
+ throw new TypeError('Object-specific non-reproduction requires a saved baseline question and comparable evidence.');
159
+ assertCaseObjectObservationOutcome(review.objectOutcome, review.recheck, review.completeness, evidencePins, pin, review.observedAt);
140
160
  if (!review.recheck)
141
161
  continue;
142
162
  assertCurrentRecheckQuestion(review.recheck, assertions);
143
163
  assertRecheckNonReproduction(review.state, review.recheck, review.completeness, evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), review.observedAt);
144
164
  }
145
165
  const closures = input.closure !== undefined
146
- ? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
166
+ ? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions, evidencePins)
147
167
  : normalizeCaseClosureHistory(undefined, now);
148
168
  if (caseStatusRequiresClosure(normalizeStatus(input.status)) && input.closure === undefined) {
149
169
  throw new Error('Opening a resolved case requires a deliberate closure reason and its linked review context.');
@@ -152,12 +172,12 @@ export function createCase(input, nowIso) {
152
172
  id: makeId(),
153
173
  domain,
154
174
  title: normalizeCaseObjective(input.title),
155
- status: input.closure !== undefined ? 'resolved' : normalizeStatus(input.status),
175
+ status: input.closure !== undefined && objectRecord(input.closure).responseObject === undefined ? 'resolved' : normalizeStatus(input.status),
156
176
  disposition: normalizeDisposition(input.disposition),
157
177
  reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
158
178
  brandProfileIds: input.brandProfileIds === undefined ? [] : assertCaseBrandProfileIds(input.brandProfileIds),
159
179
  tags: normalizeTags(input.tags),
160
- workflowMetadata: updateCaseWorkflowMetadata(emptyCaseWorkflowMetadata(), input, domain, now),
180
+ workflowMetadata,
161
181
  notes: noteBody ? [{ id: makeId(), body: noteBody, createdAt: now }] : [],
162
182
  source,
163
183
  evidenceHistory: normalizeEvidenceHistory(input.evidence ? [input.evidence] : [], {
@@ -246,6 +266,21 @@ export function updateCase(cases, id, patch, nowIso) {
246
266
  const current = cases[index];
247
267
  if (!current)
248
268
  throw new Error('That case no longer exists.');
269
+ if (patch.expectedResponseContext !== undefined && patch.expectedResponseContext !== JSON.stringify(current))
270
+ throw new TypeError('The Case response context changed after preview. Review the current delivery, scope and evidence before creating this draft.');
271
+ const workflowMetadata = updateCaseWorkflowMetadata(current.workflowMetadata
272
+ ?? readCaseWorkflowFields(current, current.domain, current.assertions).workflowMetadata, patch, current.domain, now);
273
+ const scopedCase = { ...current, workflowMetadata };
274
+ for (const raw of [patch.evidencePin, ...(Array.isArray(patch.evidencePins) ? patch.evidencePins : []), patch.observedEffectReview, patch.closure]) {
275
+ assertCaseResponseObject(readCaseResponseObject(objectRecord(raw).responseObject), scopedCase);
276
+ }
277
+ for (const raw of [patch.action, patch.actionUpdate, objectRecord(patch.actionUpdate).transition]) {
278
+ for (const object of readCaseResponseObjects(objectRecord(raw).responseObjects) ?? [])
279
+ assertCaseResponseObject(object, scopedCase);
280
+ }
281
+ for (const raw of [patch.assertion, patch.assertionUpdate, patch.observedEffectReview]) {
282
+ assertCaseResponseObject(readCaseResponseObject(objectRecord(objectRecord(raw).recheck).responseObject), scopedCase);
283
+ }
249
284
  if (patch.evidenceLink !== undefined && patch.evidenceLinkWithdrawal !== undefined)
250
285
  throw new TypeError('Record or withdraw one evidence relationship at a time.');
251
286
  if (patch.title !== undefined && patch.expectedTitle !== undefined && patch.expectedTitle !== (current.title ?? '')) {
@@ -284,6 +319,17 @@ export function updateCase(cases, id, patch, nowIso) {
284
319
  evidencePins = appendCaseEvidencePin(evidencePins, patch.evidencePin, now);
285
320
  }
286
321
  const pinIds = new Set(evidencePins.map((item) => item.id));
322
+ const correctionInput = patch.action !== undefined ? objectRecord(patch.action) : patch.actionUpdate !== undefined
323
+ ? { ...current.actions.find(action => action.id === objectRecord(patch.actionUpdate).id), ...objectRecord(patch.actionUpdate) } : null;
324
+ if (correctionInput?.correction !== undefined)
325
+ correctionDelivery(current.actions, {
326
+ originActionId: typeof correctionInput.originActionId === 'string' ? correctionInput.originActionId : null,
327
+ recipient: typeof correctionInput.recipient === 'string' ? correctionInput.recipient : '',
328
+ responseObjects: readCaseResponseObjects(correctionInput.responseObjects), correction: readCasePacketCorrection(correctionInput.correction),
329
+ }, { caseId: current.id, target: current.domain, selectedPinIds: pinIds });
330
+ const transitionReceipt = readCaseDeliveryPacketReceipt(objectRecord(objectRecord(patch.actionUpdate).transition).packetReceipt);
331
+ if (transitionReceipt && (transitionReceipt.caseId !== current.id || transitionReceipt.target !== current.domain))
332
+ throw new TypeError('The packet receipt belongs to a different Case or target.');
287
333
  let decisions = current.decisions;
288
334
  if (patch.decision !== undefined) {
289
335
  decisions = appendCaseDecision(current.decisions, patch.decision, now, pinIds);
@@ -295,6 +341,16 @@ export function updateCase(cases, id, patch, nowIso) {
295
341
  if (patch.actionUpdate !== undefined) {
296
342
  actions = updateCaseAction(actions, patch.actionUpdate, now, pinIds);
297
343
  }
344
+ if (correctionInput?.correction !== undefined || transitionReceipt) {
345
+ for (const original of current.actions) {
346
+ const retained = actions.find(action => action.id === original.id);
347
+ if (!retained || original.history.some(event => !retained.history.some(next => JSON.stringify(next) === JSON.stringify(event)))
348
+ || (patch.action !== undefined && JSON.stringify(retained) !== JSON.stringify(original)))
349
+ throw new TypeError('This linked operation would omit or change historical delivery records within the Case bounds. No correction or receipt was retained.');
350
+ }
351
+ if (transitionReceipt && !actions.find(action => action.id === transitionReceipt.actionId)?.history.some(event => JSON.stringify(event.packetReceipt) === JSON.stringify(transitionReceipt)))
352
+ throw new TypeError('The exact delivery receipt exceeds the retained history bounds. No delivery was recorded.');
353
+ }
298
354
  let assertions = current.assertions;
299
355
  if (patch.assertion !== undefined) {
300
356
  assertions = appendCaseAssertion(current.assertions, patch.assertion, now, pinIds);
@@ -326,22 +382,28 @@ export function updateCase(cases, id, patch, nowIso) {
326
382
  assertCurrentRecheckQuestion(context, assertions);
327
383
  assertRecheckNonReproduction(review.state, context, String(review.completeness), evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), typeof review.observedAt === 'string' ? review.observedAt : null);
328
384
  }
385
+ const responseObject = readCaseResponseObject(review.responseObject);
386
+ const pin = evidencePins.find(item => item.id === review.evidencePinId);
387
+ assertCaseObjectObservationOutcome(review.objectOutcome, context, String(review.completeness), evidencePins, pin, typeof review.observedAt === 'string' ? review.observedAt : null);
388
+ if (responseObject && pin && !sameCaseResponseObject(responseObject, pin.responseObject))
389
+ throw new TypeError('The selected observation evidence does not explicitly concern this exact object.');
390
+ if (responseObject && review.state === 'not_reproduced' && !context)
391
+ throw new TypeError('Object-specific non-reproduction requires a saved baseline question and comparable evidence.');
329
392
  observedEffects = appendCaseObservedEffectReview(observedEffects, patch.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)));
330
393
  }
331
394
  let closures = normalizeCaseClosureHistory(current.closures, current.updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), {}, buildCaseClosureLinkContext(observedEffects, actions));
332
395
  if (patch.closure !== undefined) {
333
- closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions);
396
+ closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions, evidencePins);
334
397
  }
335
398
  if (caseStatusRequiresClosure(patch.status) && patch.closure === undefined) {
336
399
  throw new Error('Resolve this case through the deliberate closure review so the reason and evidence state remain explicit.');
337
400
  }
338
401
  const record = {
339
402
  ...current,
340
- workflowMetadata: updateCaseWorkflowMetadata(current.workflowMetadata
341
- ?? readCaseWorkflowFields(current, current.domain, current.assertions).workflowMetadata, patch, current.domain, now),
403
+ workflowMetadata,
342
404
  title: patch.title === undefined ? current.title ?? '' : normalizeCaseObjective(patch.title),
343
405
  status: patch.closure !== undefined
344
- ? 'resolved'
406
+ ? objectRecord(patch.closure).responseObject !== undefined ? current.status : 'resolved'
345
407
  : patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
346
408
  disposition: patch.disposition !== undefined ? normalizeDisposition(patch.disposition) : current.disposition,
347
409
  reviewReasonCode: patch.reviewReasonCode !== undefined
@@ -504,6 +566,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
504
566
  observedAt: input.observedAt,
505
567
  collectionDepth: input.collectionDepth,
506
568
  observationHostname: input.observationHostname,
569
+ ...(input.responseObject ? { responseObject: input.responseObject } : {}),
507
570
  completeness: input.completeness,
508
571
  truncated: input.comparisonTruncated === true
509
572
  || (typeof input.comparisonSummary === 'string' && input.comparisonSummary.length > MAX_RESPONSE_VALUE_LENGTH),
@@ -524,6 +587,8 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
524
587
  followUpAt: input.followUpAt,
525
588
  limitations: input.limitations,
526
589
  ...(input.recheck ? { recheck: input.recheck } : {}),
590
+ ...(input.responseObject ? { responseObject: input.responseObject } : {}),
591
+ ...(input.objectOutcome ? { objectOutcome: input.objectOutcome } : {}),
527
592
  },
528
593
  }, now);
529
594
  }
@@ -102,15 +102,26 @@ const CASE_FIELD_RULES = Object.freeze({
102
102
  }),
103
103
  source: preservedField('source'),
104
104
  evidenceHistory: fieldRule('evidenceHistory', PRESERVE, (record) => structuredClone(record.evidenceHistory)),
105
- evidencePins: fieldRule('evidencePins', PRESERVE, (record) => structuredClone(record.evidencePins)),
105
+ evidencePins: fieldRule('evidencePins', PUBLIC_REDACT, (record, profile) => structuredClone(record.evidencePins).map(pin => {
106
+ if (profile !== 'public')
107
+ return pin;
108
+ const { responseObject: _scope, infrastructureObservation: _infrastructure, ...publicPin } = pin;
109
+ return publicPin;
110
+ }), { nestedSensitiveFields: ['responseObject', 'infrastructureObservation'], audienceExclusions: { public: { label: 'Exact-object evidence scope and infrastructure observations', order: 4, sinceVersion: 18 } } }),
106
111
  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
112
  decisions: fieldRule('decisions', PRESERVE, (record) => structuredClone(record.decisions)),
108
113
  actions: fieldRule('actions', TRUSTED_REDACT_PUBLIC_EXCLUDE, (record, profile) => (profile === 'public'
109
114
  ? []
110
115
  : profile === 'trusted'
111
- ? structuredClone(record.actions).map((item) => ({ ...item, recipient: '[redacted]' }))
116
+ ? structuredClone(record.actions).map((item) => {
117
+ const { correction: _privateCorrection, ...shared } = item;
118
+ return { ...shared, recipient: '[redacted]', history: shared.history.map(event => {
119
+ const { packetReceipt: _privateReceipt, ...sharedEvent } = event;
120
+ return sharedEvent;
121
+ }) };
122
+ })
112
123
  : structuredClone(record.actions)), {
113
- nestedSensitiveFields: ['recipient'],
124
+ nestedSensitiveFields: ['recipient', 'responseObjects', 'correction', 'packetReceipt'],
114
125
  audienceExclusions: {
115
126
  trusted: { label: 'Recipient values', order: 2 },
116
127
  public: { label: 'Actions and recipient values', order: 3 },
@@ -191,6 +202,7 @@ const OUTSIDE_SCHEMA_EXCLUSIONS = Object.freeze({
191
202
  });
192
203
  function currentAudienceExclusions(audience, caseVersion) {
193
204
  const entries = [
205
+ ...(audience === 'trusted' && caseVersion >= 19 ? [{ label: 'Private correction content and exact delivery receipts', order: 2 }] : []),
194
206
  ...Object.values(CASE_FIELD_RULES).flatMap((rule) => (rule.audienceExclusions[audience] ? [rule.audienceExclusions[audience]] : [])),
195
207
  OUTSIDE_SCHEMA_EXCLUSIONS[audience],
196
208
  ];
@@ -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
  }
@@ -14,9 +14,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
16
  import { caseTypeSummary } from "./case-workflow-metadata.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,
@@ -279,6 +284,10 @@ export function buildCaseReportVerificationProjection(caseRecord, options, schem
279
284
  const generated = buildCaseReport(caseRecord, options).json;
280
285
  if (schemaVersion === CASE_REPORT_SCHEMA_VERSION)
281
286
  return generated;
287
+ if (schemaVersion === OBJECT_RESPONSE_CASE_REPORT_SCHEMA_VERSION)
288
+ return { ...generated, schemaVersion };
289
+ if (schemaVersion === EVIDENCE_FOLLOW_UP_CASE_REPORT_SCHEMA_VERSION)
290
+ return { ...generated, schemaVersion };
282
291
  if (caseRecord.evidenceHistory.some(snapshot => snapshot.webCollectionQuality !== undefined)) {
283
292
  throw new TypeError('Published report formats cannot declare newer collection-quality fields.');
284
293
  }
@@ -499,6 +508,8 @@ function buildMarkdown(report, includeAttribution) {
499
508
  for (const pin of response.evidencePins) {
500
509
  lines.push(`- **${escapeMarkdownInline(pin.label)}:** ${escapeMarkdownInline(pin.value)}`);
501
510
  lines.push(` Source: ${escapeMarkdownInline(pin.source)}; observed ${escapeMarkdownInline(pin.observedAt ?? 'Time unavailable')}; completeness ${escapeMarkdownInline(pin.completeness)}.`);
511
+ if (pin.responseObject)
512
+ lines.push(` Exact object: ${escapeMarkdownInline(pin.responseObject.kind)} · ${escapeMarkdownInline(pin.responseObject.identifier)}`);
502
513
  if (pin.limitations.length)
503
514
  lines.push(` Limitations: ${escapeMarkdownInline(pin.limitations.join('; '))}`);
504
515
  }
@@ -587,6 +598,7 @@ function buildMarkdown(report, includeAttribution) {
587
598
  for (const action of response.actions) {
588
599
  lines.push(`- **${escapeMarkdownInline(action.type.replaceAll('_', ' '))}:** ${escapeMarkdownInline(action.recipient)} (${escapeMarkdownInline(action.state)})`);
589
600
  lines.push(` Contact source: ${escapeMarkdownInline(action.contactSource)}; updated ${escapeMarkdownInline(action.updatedAt)}.`);
601
+ 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
602
  if (action.routeObservedAt)
591
603
  lines.push(` Route reviewed: ${escapeMarkdownInline(action.routeObservedAt)}`);
592
604
  if (action.routeReviewAfter)
@@ -595,6 +607,8 @@ function buildMarkdown(report, includeAttribution) {
595
607
  lines.push(` Originating action: ${escapeMarkdownInline(action.originActionId)}`);
596
608
  if (action.amendment)
597
609
  lines.push(` Amendment of submitted packet SHA-256: ${action.amendment.packetDigestSha256}; request events: ${action.amendment.requestEventIds.map(escapeMarkdownInline).join(', ')}`);
610
+ if (action.correction)
611
+ 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
612
  for (const event of action.history)
599
613
  if (event.evidenceRequest) {
600
614
  const request = event.evidenceRequest;
@@ -616,8 +630,14 @@ function buildMarkdown(report, includeAttribution) {
616
630
  lines.push(` - ${escapeMarkdownInline(event.occurredAt)} · ${escapeMarkdownInline(event.previousState ?? 'none')} → ${escapeMarkdownInline(event.nextState)} · ${escapeMarkdownInline(event.sourceClass)} · ${event.applied ? 'applied' : 'retained conflict'}`);
617
631
  if (event.providerOutcome)
618
632
  lines.push(` Provider outcome at this time: ${escapeMarkdownInline(event.providerOutcome.replaceAll('_', ' '))}`);
633
+ if (event.responseObjects?.length)
634
+ lines.push(` Objects at this event: ${event.responseObjects.map(object => `${escapeMarkdownInline(object.kind)} · ${escapeMarkdownInline(object.identifier)}`).join('; ')}`);
635
+ if (event.objectOutcome)
636
+ lines.push(` Source-reported object outcome: ${escapeMarkdownInline(event.objectOutcome)}; not independent verification or causation.`);
619
637
  if (event.reference)
620
638
  lines.push(` Event reference: ${escapeMarkdownInline(event.reference)}`);
639
+ if (event.packetReceipt)
640
+ 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
641
  if (event.evidencePinId)
622
642
  lines.push(` Evidence pin: ${escapeMarkdownInline(event.evidencePinId)}`);
623
643
  if (event.limitations.length)
@@ -667,6 +687,10 @@ function buildMarkdown(report, includeAttribution) {
667
687
  lines.push('- No independent observed-effect review recorded.');
668
688
  for (const review of response.observedEffects.reviews) {
669
689
  lines.push(`- **${escapeMarkdownInline(review.state.replaceAll('_', ' '))}** (${escapeMarkdownInline(review.observedAt)}): ${escapeMarkdownInline(review.source)}; class ${escapeMarkdownInline(review.sourceClass)}; completeness ${escapeMarkdownInline(review.completeness)}.`);
690
+ if (review.responseObject)
691
+ lines.push(` Exact object: ${escapeMarkdownInline(review.responseObject.kind)} · ${escapeMarkdownInline(review.responseObject.identifier)}`);
692
+ if (review.objectOutcome)
693
+ lines.push(` Independently recorded object outcome: ${escapeMarkdownInline(review.objectOutcome)}; point-in-time observation, not report causation or recurrence.`);
670
694
  if (review.recheck) {
671
695
  lines.push(` Question ${escapeMarkdownInline(review.recheck.questionId)}: ${escapeMarkdownInline(review.recheck.question)}`);
672
696
  lines.push(` Recheck target: ${escapeMarkdownInline(review.recheck.targetHostname)}; retained comparison conditions: ${escapeMarkdownInline(review.recheck.conditions)}`);
@@ -689,13 +713,19 @@ function buildMarkdown(report, includeAttribution) {
689
713
  lines.push('- No deliberate analyst closure recorded.');
690
714
  for (const closure of response.closures.records) {
691
715
  lines.push(`- **Closure · ${escapeMarkdownInline(closure.reason.replaceAll('_', ' '))}** (${escapeMarkdownInline(closure.createdAt)}): ${escapeMarkdownInline(closure.summary)}`);
716
+ 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
717
  if (closure.observedEffectReviewId)
693
718
  lines.push(` Independent review: ${escapeMarkdownInline(closure.observedEffectReviewId)}`);
694
719
  if (closure.actionId)
695
720
  lines.push(` Provider action: ${escapeMarkdownInline(closure.actionId)}`);
721
+ const qualification = caseClosureHistoryQualification(closure, response.actions);
722
+ if (qualification)
723
+ lines.push(` History qualification: ${escapeMarkdownInline(qualification)}`);
696
724
  if (closure.limitations.length)
697
725
  lines.push(` Limitations: ${escapeMarkdownInline(closure.limitations.join('; '))}`);
698
726
  }
727
+ if (response.closures.omitted)
728
+ lines.push(`- Closure records not retained: ${response.closures.omitted}. ${escapeMarkdownInline(response.closures.limitations.join('; '))}`);
699
729
  lines.push('');
700
730
  // Analyst notes (only when included)
701
731
  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