@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
@@ -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
+ }
@@ -1,7 +1,8 @@
1
1
  // Independent observed effects, linked closures and lifecycle summaries.
2
2
  import { MAX_CASE_CLOSURES, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, } from "../contracts/case-portability.mjs";
3
3
  import { latestObservationCohort, } from "../evidence/latest-observations.mjs";
4
- import { readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
4
+ import { readCaseRecheckAnswerContext, assertRecheckNonReproduction, COMPARATIVE_CASE_OBJECT_OUTCOMES } from "./case-recheck-model.mjs";
5
+ import { readCaseResponseObject, readCaseResponseObjectOutcome, assertCaseObjectOutcome, sameCaseResponseObject } from "./case-response-object.mjs";
5
6
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
6
7
  import { CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, } from "./case-response-records.mjs";
7
8
  import { COMPLETENESS, SAFE_ID_RE, boundedCounter, compareCodeUnits, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
@@ -20,6 +21,16 @@ function normalizeObservedEffectReview(raw, fallback, validPinIds, validSighting
20
21
  return null;
21
22
  const createdAt = iso(item.createdAt, observedAt || fallback, options);
22
23
  const recheck = readCaseRecheckAnswerContext(item.recheck, options.sourceVersion);
24
+ const responseObject = readCaseResponseObject(item.responseObject, options.sourceVersion);
25
+ const objectOutcome = readCaseResponseObjectOutcome(item.objectOutcome, options.sourceVersion);
26
+ assertCaseObjectOutcome(objectOutcome, responseObject);
27
+ if (objectOutcome && (item.state === 'unavailable' || item.state === 'not_checked'))
28
+ throw new TypeError('Unavailable or unchecked collection cannot establish an object outcome.');
29
+ if (objectOutcome && COMPARATIVE_CASE_OBJECT_OUTCOMES.includes(objectOutcome)
30
+ && (item.completeness !== 'complete' || !recheck?.responseObject || recheck.conditionsMatch !== 'comparable'))
31
+ throw new TypeError('An independently observed object state change requires complete evidence and comparable exact-object conditions.');
32
+ if (recheck?.responseObject && !sameCaseResponseObject(recheck.responseObject, responseObject))
33
+ throw new TypeError('The recheck answer and observation must concern the same exact object.');
23
34
  if (recheck && item.state === 'not_reproduced' && (item.completeness !== 'complete' || recheck.conditionsMatch !== 'comparable')) {
24
35
  throw new TypeError('A question cannot be marked not reproduced from incomplete evidence or unconfirmed comparison conditions.');
25
36
  }
@@ -51,6 +62,8 @@ function normalizeObservedEffectReview(raw, fallback, validPinIds, validSighting
51
62
  sightingId,
52
63
  followUpAt: optionalIso(item.followUpAt, options),
53
64
  ...(recheck ? { recheck } : {}),
65
+ ...(responseObject ? { responseObject } : {}),
66
+ ...(objectOutcome ? { objectOutcome } : {}),
54
67
  createdAt,
55
68
  };
56
69
  }
@@ -128,6 +141,7 @@ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options
128
141
  if (!summary)
129
142
  return null;
130
143
  const createdAt = iso(item.createdAt, fallback, options);
144
+ const responseObject = readCaseResponseObject(item.responseObject, options.sourceVersion);
131
145
  const observedEffectReviewId = typeof item.observedEffectReviewId === 'string'
132
146
  && SAFE_ID_RE.test(item.observedEffectReviewId)
133
147
  && (!validReviewIds || validReviewIds.has(item.observedEffectReviewId))
@@ -150,7 +164,8 @@ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options
150
164
  return null;
151
165
  const linkedProviderEvents = actionId ? linkContext.providerResolutionEvents?.get(actionId) ?? [] : [];
152
166
  if (linkContext.providerResolutionEvents && reason === 'provider_reported_resolution_not_independently_checked'
153
- && !linkedProviderEvents.some((event) => Date.parse(event.occurredAt) <= Date.parse(createdAt)))
167
+ && !linkedProviderEvents.some((event) => Date.parse(event.occurredAt) <= Date.parse(createdAt)
168
+ && (responseObject ? event.responseObjects?.some(object => sameCaseResponseObject(object, responseObject)) : !event.responseObjects?.length)))
154
169
  return null;
155
170
  const linkLimitations = [
156
171
  ...(item.observedEffectReviewId != null && !observedEffectReviewId ? ['A malformed or dangling observed-effect review reference was omitted from this closure.'] : []),
@@ -158,6 +173,7 @@ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options
158
173
  ];
159
174
  return {
160
175
  id: safeId(item.id, 'case-closure', { reason: item.reason, summary, createdAt }),
176
+ ...(responseObject === undefined ? {} : { responseObject }),
161
177
  reason,
162
178
  summary,
163
179
  observedEffectReviewId,
@@ -174,8 +190,11 @@ export function buildCaseClosureLinkContext(observedEffects, actions) {
174
190
  createdAt: review.createdAt,
175
191
  }])),
176
192
  providerResolutionEvents: new Map(actions.map((action) => [action.id, action.history
177
- .filter((event) => event.applied && event.providerOutcome === 'provider_reports_resolved')
178
- .map((event) => ({ eventId: event.id, occurredAt: event.occurredAt }))])),
193
+ // Reconciliation can change projection, but not the identity of a retained
194
+ // typed receipt supporting an already-authored historical decision.
195
+ .filter((event) => event.providerOutcome === 'provider_reports_resolved')
196
+ .map((event) => ({ eventId: event.id, occurredAt: event.occurredAt,
197
+ ...(event.responseObjects === undefined ? {} : { responseObjects: event.responseObjects }) }))])),
179
198
  };
180
199
  }
181
200
  export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, validActionIds, options = {}, linkContext = {}) {
@@ -224,7 +243,9 @@ export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, valid
224
243
  };
225
244
  }
226
245
  /** New-write policy only. Historical normalisation must not re-adjudicate a closure. */
227
- export function caseClosureReviewBlocker(reason, review, now) {
246
+ export function caseClosureReviewBlocker(reason, review, now, responseObject, evidencePins = []) {
247
+ if (review?.responseObject && !sameCaseResponseObject(responseObject, review.responseObject))
248
+ return 'This independent review concerns one object. Select that object for closure; it cannot close the whole Case.';
228
249
  if (reason !== 'independently_not_reproduced' && reason !== 'infrastructure_changed')
229
250
  return null;
230
251
  const expected = reason === 'independently_not_reproduced' ? 'not_reproduced' : 'changed';
@@ -233,6 +254,18 @@ export function caseClosureReviewBlocker(reason, review, now) {
233
254
  : 'This closure reason requires a linked independent changed review.';
234
255
  if (!review || review.state !== expected)
235
256
  return invalidLink;
257
+ if (responseObject) {
258
+ if (!sameCaseResponseObject(responseObject, review.responseObject))
259
+ return 'Object-specific technical closure requires a linked independent review explicitly bound to this exact object; historical missing binding remains unknown.';
260
+ if (!review.recheck || !sameCaseResponseObject(responseObject, review.recheck.responseObject))
261
+ return 'Object-specific technical closure requires a complete exact-object baseline and current review under comparable conditions.';
262
+ try {
263
+ assertRecheckNonReproduction('not_reproduced', review.recheck, review.completeness, evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), review.observedAt);
264
+ }
265
+ catch (cause) {
266
+ return cause instanceof Error ? cause.message : 'The exact-object review cannot support technical closure.';
267
+ }
268
+ }
236
269
  const observedAt = normalizeExplicitIsoTimestamp(review.observedAt);
237
270
  const createdAt = normalizeExplicitIsoTimestamp(review.createdAt);
238
271
  const closedAt = normalizeExplicitIsoTimestamp(now);
@@ -248,7 +281,43 @@ export function caseClosureReviewBlocker(reason, review, now) {
248
281
  }
249
282
  return null;
250
283
  }
251
- export function appendCaseClosure(current, raw, now, observedEffects, actions) {
284
+ /** Latest applicable provider cohort, never the action-wide summary or an old receipt. */
285
+ export function caseClosureProviderBlocker(action, responseObject, now) {
286
+ const blocked = 'This closure reason requires a linked typed provider-reported-resolution outcome for the latest applicable object observation.';
287
+ const closedAt = normalizeExplicitIsoTimestamp(now);
288
+ if (!action || !closedAt)
289
+ return blocked;
290
+ if (responseObject ? !action.responseObjects?.some(object => sameCaseResponseObject(object, responseObject)) : action.responseObjects?.length)
291
+ return blocked;
292
+ // A losing workflow transition is still retained evidence. Select the
293
+ // exact-object cohort before checking whether its receipts were applied.
294
+ const events = action.history.filter(event => (event.providerOutcome !== null || event.objectOutcome !== undefined)
295
+ && Date.parse(event.occurredAt) <= Date.parse(closedAt)
296
+ && (responseObject ? event.responseObjects?.some(object => sameCaseResponseObject(object, responseObject)) : !event.responseObjects?.length));
297
+ const cohort = latestObservationCohort(events, event => event.occurredAt);
298
+ if (!cohort.latest.length || cohort.undated.length || cohort.latest.some(event => !event.applied || event.providerOutcome !== 'provider_reports_resolved'
299
+ || event.objectOutcome === 'restored' || event.objectOutcome === 'disputed')
300
+ || new Set(cohort.latest.map(event => event.objectOutcome ?? null)).size !== 1)
301
+ return blocked;
302
+ return null;
303
+ }
304
+ export function caseClosureActionBlocker(reason, action, responseObject, now) {
305
+ if (action?.responseObjects?.length && (!responseObject || !action.responseObjects.some(object => sameCaseResponseObject(object, responseObject))))
306
+ return 'This action concerns explicitly bound objects. Select one of them for this closure; other objects remain independent.';
307
+ return reason === 'provider_reported_resolution_not_independently_checked' ? caseClosureProviderBlocker(action, responseObject, now) : null;
308
+ }
309
+ /** Derived presentation only: never rewrite the analyst's summary or limitations. */
310
+ export function caseClosureHistoryQualification(closure, actions) {
311
+ if (closure.reason !== 'provider_reported_resolution_not_independently_checked')
312
+ return null;
313
+ const action = actions.find(candidate => candidate.id === closure.actionId);
314
+ // Evaluate the retained history, not a wall-clock freshness claim. Future-dated
315
+ // or conflicting receipts cannot silently make an old decision authoritative.
316
+ const latestAt = (action?.history ?? []).reduce((latest, event) => Date.parse(event.occurredAt) > Date.parse(latest) ? event.occurredAt : latest, closure.createdAt);
317
+ return caseClosureProviderBlocker(action, closure.responseObject, latestAt) === null ? null
318
+ : 'As of the latest retained receipt, provider history does not support a new closure for this scope. This historical analyst decision is preserved; it does not establish current remediation.';
319
+ }
320
+ export function appendCaseClosure(current, raw, now, observedEffects, actions, evidencePins = []) {
252
321
  const item = record(raw);
253
322
  const reason = typeof item.reason === 'string' && CLOSURE_REASONS.has(item.reason)
254
323
  ? item.reason
@@ -259,16 +328,13 @@ export function appendCaseClosure(current, raw, now, observedEffects, actions) {
259
328
  const action = typeof item.actionId === 'string'
260
329
  ? actions.find((candidate) => candidate.id === item.actionId) ?? null
261
330
  : null;
262
- const reviewBlocker = caseClosureReviewBlocker(reason, review, now);
331
+ const responseObject = readCaseResponseObject(item.responseObject);
332
+ const reviewBlocker = caseClosureReviewBlocker(reason, review, now, responseObject, evidencePins);
263
333
  if (reviewBlocker)
264
334
  throw new Error(reviewBlocker);
265
- if (reason === 'provider_reported_resolution_not_independently_checked'
266
- && (action?.providerOutcome !== 'provider_reports_resolved'
267
- || !action.history.some((event) => event.applied
268
- && event.providerOutcome === 'provider_reports_resolved'
269
- && Date.parse(event.occurredAt) <= Date.parse(now)))) {
270
- throw new Error('This closure reason requires a linked typed provider-reported-resolution outcome.');
271
- }
335
+ const actionBlocker = caseClosureActionBlocker(reason, action, responseObject, now);
336
+ if (actionBlocker)
337
+ throw new Error(actionBlocker);
272
338
  const linkContext = buildCaseClosureLinkContext(observedEffects, actions);
273
339
  const created = normalizeClosure({ ...item, id: freshId('case-closure'), createdAt: now }, now, new Set(observedEffects.reviews.map((candidate) => candidate.id)), new Set(actions.map((candidate) => candidate.id)), {}, linkContext);
274
340
  if (!created || !reason)
@@ -314,6 +380,7 @@ export function buildCaseResponseLifecycleSummary(input) {
314
380
  ? 'missing'
315
381
  : latestObservedChangeAt ? 'available' : 'ambiguous';
316
382
  const latestClosure = [...(input.closures?.records ?? [])]
383
+ .filter(closure => closure.responseObject === undefined)
317
384
  .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || compareCodeUnits(right.id, left.id))[0] ?? null;
318
385
  return {
319
386
  providerOutcomeState,
@@ -3,6 +3,7 @@ import { escapeCaseMarkdownInline as escapeMarkdown } from "./case-markdown.mjs"
3
3
  /** Render only the projected packet, never the source Case or private inputs. */
4
4
  export function renderCaseResponsePacket(packet) {
5
5
  const { profile, case: caseRecord, incident: { category, affectedParty, abusiveUrls, observedHarm, observedAt }, generatedAt: normalizedGeneratedAt, recipientRoute, escalationHistory, escalationHistoryOmitted, escalationHistoryLimitations, authorisation, readiness, selectedEvidence, artefactReferences, responseLifecycle, preflight, provenance: { limitations, evidencePinCount, decisionCount, assertionCount, observationAge: age }, integrity: { digestSha256 }, } = packet;
6
+ const selectedCorrection = escalationHistory.find(action => action.actionId === packet.actionBinding.selectedActionId)?.correction;
6
7
  const lines = [
7
8
  `# ${escapeMarkdown(profile.label)} packet`,
8
9
  '',
@@ -14,7 +15,7 @@ export function renderCaseResponsePacket(packet) {
14
15
  `**Audience:** ${escapeMarkdown(profile.audience)}`,
15
16
  `**Suggested subject:** ${escapeMarkdown(profile.subject)}`,
16
17
  '',
17
- '## Observed harm',
18
+ selectedCorrection ? '## Retained incident context (not a renewed allegation)' : '## Observed harm',
18
19
  '',
19
20
  escapeMarkdown(observedHarm),
20
21
  '',
@@ -48,9 +49,14 @@ export function renderCaseResponsePacket(packet) {
48
49
  ...(action.outcomeDetail ? [` - Outcome detail: ${escapeMarkdown(action.outcomeDetail)}`] : []),
49
50
  ...(action.originActionId ? [` - Originating action: ${escapeMarkdown(action.originActionId)}`] : []),
50
51
  ...(action.amendment ? [` - Amendment of submitted packet SHA-256: ${action.amendment.packetDigestSha256}`, ` - Prepared request events: ${action.amendment.requestEventIds.map(escapeMarkdown).join(', ')}`] : []),
52
+ ...(action.correction ? [` - ${action.correction.purpose === 'correction' ? 'Correction request' : 'Retraction request'} for delivery event ${escapeMarkdown(action.correction.deliveryEventId)}; original packet v${action.correction.packetVersion} SHA-256: ${action.correction.packetDigestSha256}`,
53
+ ` - Analyst reason: ${escapeMarkdown(action.correction.reason)}`, ` - Previous statement: ${escapeMarkdown(action.correction.previousStatement)}`,
54
+ ` - Corrected statement: ${escapeMarkdown(action.correction.correctedStatement || 'Request to retract the previous statement')}`,
55
+ ` - Corrected evidence pins: ${action.correction.evidencePinIds.map(escapeMarkdown).join(', ')}`] : []),
51
56
  ...action.transitions.flatMap((event) => [
52
57
  ` - ${event.occurredAt}: ${escapeMarkdown(event.previousState ?? 'none')} → ${escapeMarkdown(event.nextState)} · ${escapeMarkdown(event.sourceClass)} · ${escapeMarkdown(event.provenance)}${event.providerOutcome ? ` · ${escapeMarkdown(event.providerOutcome.replaceAll('_', ' '))}` : ''}${event.applied ? '' : ' · retained conflict'}`,
53
58
  ...(event.reference ? [` - Reference: ${escapeMarkdown(event.reference)}`] : []),
59
+ ...(event.packetReceipt ? [` - Exact local packet receipt: v${event.packetReceipt.packetVersion} · ${event.packetReceipt.packetDigestSha256} · ${escapeMarkdown(event.packetReceipt.recipient)} · ${escapeMarkdown(event.packetReceipt.profile)} · generated ${event.packetReceipt.packetGeneratedAt}`] : []),
54
60
  ...(event.evidencePinId ? [` - Evidence pin: ${escapeMarkdown(event.evidencePinId)}`] : []),
55
61
  ...(event.evidenceRequest ? [
56
62
  ` - Requested evidence (${event.evidenceRequest.state}): ${escapeMarkdown(event.evidenceRequest.summary)}`,
@@ -86,6 +92,10 @@ export function renderCaseResponsePacket(packet) {
86
92
  '',
87
93
  '## Provider outcome and independent effect',
88
94
  '',
95
+ ...escalationHistory.flatMap(action => [
96
+ `- Action ${escapeMarkdown(action.actionId)} object scope: ${action.responseObjects?.length ? action.responseObjects.map(object => `${escapeMarkdown(object.kind)} · ${escapeMarkdown(object.identifier)}`).join('; ') : 'unknown; no exact-object coverage inferred'}`,
97
+ ...action.transitions.filter(event => event.objectOutcome).map(event => ` - ${escapeMarkdown(event.objectOutcome)} · ${escapeMarkdown(event.sourceClass)} · ${event.occurredAt} · affected objects: ${event.responseObjects?.map(object => `${escapeMarkdown(object.kind)} · ${escapeMarkdown(object.identifier)}`).join('; ')}${event.applied ? '' : ' · retained conflict'}`),
98
+ ]),
89
99
  responseLifecycle.latestProviderOutcome
90
100
  ? `- Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${escapeMarkdown(responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' '))})`
91
101
  : `- Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,
@@ -93,6 +103,7 @@ export function renderCaseResponsePacket(packet) {
93
103
  ? `- Independently observed change time: ${responseLifecycle.latestObservedChangeAt}`
94
104
  : `- Independently observed change time: Withheld because the independent change state is ${responseLifecycle.observedChangeState}.`,
95
105
  ...(responseLifecycle.latestObservedEffect ? [`- Latest independent review: ${escapeMarkdown(responseLifecycle.latestObservedEffect.state.replaceAll('_', ' '))} · ${responseLifecycle.latestObservedEffect.observedAt} · ${escapeMarkdown(responseLifecycle.latestObservedEffect.source)}`] : []),
106
+ ...(responseLifecycle.latestObservedEffect?.responseObject ? [`- Reviewed object: ${escapeMarkdown(responseLifecycle.latestObservedEffect.responseObject.kind)} · ${escapeMarkdown(responseLifecycle.latestObservedEffect.responseObject.identifier)}${responseLifecycle.latestObservedEffect.objectOutcome ? ` · ${escapeMarkdown(responseLifecycle.latestObservedEffect.objectOutcome)}` : ''}`] : []),
96
107
  ...responseLifecycle.limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
97
108
  '',
98
109
  '## Review and provenance',
@@ -117,22 +128,29 @@ export function renderCaseResponsePacket(packet) {
117
128
  ...profile.followUpFields.map((item) => `- Follow-up field: ${escapeMarkdown(item)}`),
118
129
  ];
119
130
  const markdown = `${lines.join('\n').trim()}\n`;
131
+ const correction = escalationHistory.find(action => action.actionId === packet.actionBinding.selectedActionId)?.correction;
120
132
  const email = [
121
133
  `Subject: ${profile.subject}`,
122
134
  '',
123
135
  'Hello,',
124
136
  '',
125
- `I am reporting observed ${category} activity involving ${caseRecord.domain}.`,
137
+ correction ? `I am requesting ${correction.purpose === 'correction' ? 'a correction' : 'retraction or withdrawal'} of a previous statement involving ${caseRecord.domain}.`
138
+ : `I am reporting observed ${category} activity involving ${caseRecord.domain}.`,
139
+ ...(correction ? [`Original delivery event: ${correction.deliveryEventId}`, `Original canonical packet v${correction.packetVersion} SHA-256: ${correction.packetDigestSha256}`,
140
+ `Analyst reason: ${correction.reason}`, `Previous statement: ${correction.previousStatement}`,
141
+ `Corrected statement: ${correction.correctedStatement || 'Please retract the previous statement.'}`,
142
+ `Corrected evidence references: ${correction.evidencePinIds.join(', ')}`, 'This new request does not establish delivery, recipient acceptance, reversal, restoration or independent recheck.', ''] : []),
126
143
  `Affected party: ${affectedParty}`,
127
144
  `Observed at (UTC): ${observedAt}`,
128
145
  '',
129
- 'Observed harm:',
146
+ correction ? 'Retained incident context (not a renewed allegation):' : 'Observed harm:',
130
147
  observedHarm,
131
148
  '',
132
149
  'Exact URLs:',
133
150
  ...abusiveUrls.map((url) => `- ${url}`),
134
151
  '',
135
152
  'Selected evidence:',
153
+ ...escalationHistory.flatMap(action => action.responseObjects?.length ? [`Action ${action.actionId} scope: ${action.responseObjects.map(object => `${object.kind} · ${object.identifier}`).join('; ')}`] : []),
136
154
  ...(selectedEvidence.length
137
155
  ? selectedEvidence.map((item) => `- ${item.label} — ${item.source}${item.observationHostname ? ` for ${item.observationHostname}` : ''}${item.webObservationMode ? ' · selected URL' : ''}, observed ${item.observedAt ?? 'time unavailable'} (${item.completeness})`)
138
156
  : ['- No Case evidence pin was selected.']),
@@ -148,6 +166,7 @@ export function renderCaseResponsePacket(packet) {
148
166
  : []),
149
167
  '',
150
168
  'Please review this report under the applicable abuse and acceptable-use policies.',
169
+ ...limitations,
151
170
  '',
152
171
  authorisation.status === 'authorised'
153
172
  ? 'This locally prepared packet is bound to explicit review confirmations. It was not submitted automatically and does not promise any provider outcome.'
@@ -3,6 +3,9 @@ import { responseContactLabel as contactLabel } from "./case-response-packet-voc
3
3
  // Pure abuse-evidence packet builder. It creates local review artifacts only:
4
4
  // no network requests, mailto links, submissions, or provider side effects.
5
5
  import { assertPacketAmendmentSelection } from "./case-requested-evidence.mjs";
6
+ import { correctionDelivery, CASE_DELIVERY_RECEIPT_LIMITATION } from "./case-packet-correction.mjs";
7
+ import { caseSelectedEvidenceSourceLimitations } from "./case-evidence-links.mjs";
8
+ import { sameCaseResponseObject } from "./case-response-object.mjs";
6
9
  import { assertBoundedJsonStructure, } from "../analysis/bounded-json.mjs";
7
10
  import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
8
11
  import { SORTED_JSON_V2, canonicalArtifactJsonV2, } from "../evidence/artifact-integrity.mjs";
@@ -222,6 +225,9 @@ function assertAmendmentEvidence(caseRecord, input) {
222
225
  const action = selectedPacketAction(caseRecord, input);
223
226
  const selected = new Set(Array.isArray(input.selectedEvidencePinIds) ? input.selectedEvidencePinIds : []);
224
227
  assertPacketAmendmentSelection(caseRecord.actions, action?.id ?? null, new Set(caseRecord.evidencePins.filter(pin => selected.has(pin.id)).map(pin => pin.id)));
228
+ if (action?.correction)
229
+ correctionDelivery(caseRecord.actions, action, { caseId: caseRecord.id, target: caseRecord.domain,
230
+ profile: responsePacketProfile(input.profile).id, selectedPinIds: new Set(caseRecord.evidencePins.filter(pin => selected.has(pin.id)).map(pin => pin.id)) });
225
231
  }
226
232
  function packetActionLineage(caseRecord, input) {
227
233
  const selected = selectedPacketAction(caseRecord, input);
@@ -322,6 +328,7 @@ function normalizeSelectedEvidence(caseRecord, value) {
322
328
  label: text(pin.label, 80),
323
329
  source: text(pin.source, 120),
324
330
  ...(pin.observationHostname ? { observationHostname: pin.observationHostname } : {}),
331
+ ...(pin.responseObject ? { responseObject: structuredClone(pin.responseObject) } : {}),
325
332
  ...(pin.webObservationMode ? { webObservationMode: pin.webObservationMode } : {}),
326
333
  observedAt: timestamp(pin.observedAt),
327
334
  completeness: text(pin.completeness, 40),
@@ -524,7 +531,7 @@ export function buildResponsePacketProfilePreview(caseRecord, input) {
524
531
  id: profile.id,
525
532
  label: profile.label,
526
533
  audience: profile.audience,
527
- subject: `${profile.subjectPrefix}: ${caseRecord.domain} (${category})`,
534
+ subject: `${selectedPacketAction(caseRecord, input)?.correction ? 'Correction or retraction request' : profile.subjectPrefix}: ${caseRecord.domain} (${category})`,
528
535
  checklist: [...profile.checklist],
529
536
  evidenceOrder: [...profile.evidenceOrder],
530
537
  includedEvidence: [...profile.includedEvidence],
@@ -687,6 +694,8 @@ function normalizeActionHistory(caseRecord, input) {
687
694
  actionId: action.id,
688
695
  type: text(action.type, 80),
689
696
  ...(action.amendment ? { amendment: structuredClone(action.amendment) } : {}),
697
+ ...(action.correction ? { correction: structuredClone(action.correction) } : {}),
698
+ ...(action.responseObjects !== undefined ? { responseObjects: structuredClone(action.responseObjects) } : {}),
690
699
  recipient: text(action.recipient, 320),
691
700
  contactSource: text(action.contactSource, 120),
692
701
  routeObservedAt: timestamp(action.routeObservedAt),
@@ -701,6 +710,9 @@ function normalizeActionHistory(caseRecord, input) {
701
710
  transitions: action.history.map((event) => ({
702
711
  id: event.id,
703
712
  ...(event.evidenceRequest ? { evidenceRequest: structuredClone(event.evidenceRequest) } : {}),
713
+ ...(event.packetReceipt ? { packetReceipt: structuredClone(event.packetReceipt) } : {}),
714
+ ...(event.responseObjects !== undefined ? { responseObjects: structuredClone(event.responseObjects) } : {}),
715
+ ...(event.objectOutcome ? { objectOutcome: event.objectOutcome } : {}),
704
716
  previousState: event.previousState,
705
717
  nextState: event.nextState,
706
718
  occurredAt: event.occurredAt,
@@ -736,21 +748,31 @@ function normalizeActionHistory(caseRecord, input) {
736
748
  }
737
749
  function normalizeResponseLifecycle(caseRecord, actionIds, lineageComplete) {
738
750
  const scopedIds = new Set(actionIds);
751
+ const objects = caseRecord.actions.filter(action => scopedIds.has(action.id)).flatMap(action => action.responseObjects ?? []);
739
752
  const scopedRecord = {
740
753
  ...caseRecord,
741
754
  actions: caseRecord.actions.filter((action) => scopedIds.has(action.id)),
755
+ observedEffects: {
756
+ ...caseRecord.observedEffects,
757
+ reviews: caseRecord.observedEffects.reviews.filter(review => review.responseObject
758
+ && objects.some(object => sameCaseResponseObject(object, review.responseObject))),
759
+ },
742
760
  closures: {
743
761
  ...caseRecord.closures,
744
762
  records: caseRecord.closures.records.filter((closure) => closure.actionId !== null && scopedIds.has(closure.actionId)),
745
763
  },
746
764
  };
747
765
  const summary = buildCaseResponseLifecycleSummary(scopedRecord);
766
+ const latestReview = scopedRecord.observedEffects.reviews.find(review => review.id === summary.latestObservedEffect?.reviewId);
748
767
  const providerOutcomeState = lineageComplete ? summary.providerOutcomeState : 'ambiguous';
749
768
  return {
750
769
  providerOutcomeState,
751
770
  latestProviderOutcome: lineageComplete && summary.latestProviderOutcome ? { ...summary.latestProviderOutcome } : null,
752
771
  observedChangeState: summary.observedChangeState,
753
- latestObservedEffect: summary.latestObservedEffect ? { ...summary.latestObservedEffect } : null,
772
+ latestObservedEffect: summary.latestObservedEffect ? { ...summary.latestObservedEffect,
773
+ ...(latestReview?.responseObject ? { responseObject: latestReview.responseObject } : {}),
774
+ ...(latestReview?.objectOutcome ? { objectOutcome: latestReview.objectOutcome } : {}),
775
+ } : null,
754
776
  latestObservedChangeAt: summary.latestObservedChangeAt,
755
777
  closure: summary.latestClosure ? {
756
778
  id: summary.latestClosure.id,
@@ -762,6 +784,8 @@ function normalizeResponseLifecycle(caseRecord, actionIds, lineageComplete) {
762
784
  'Provider workflow outcomes and independently observed technical effects are separate point-in-time records.',
763
785
  'A provider acknowledgement, terminal state, or reported resolution never becomes independently observed remediation, absence, or safety.',
764
786
  'Times are withheld when the corresponding typed event is missing or ambiguous.',
787
+ ...(objects.length ? ['Each explicit object remains independent; a latest review does not adjudicate every object bound to the action.'] : []),
788
+ ...(caseRecord.observedEffects.reviews.length > scopedRecord.observedEffects.reviews.length ? ['Independent reviews without matching explicit action-object scope were excluded from this packet; their retained Case history is unchanged.'] : []),
765
789
  ...(!summary.latestObservedEffect && scopedRecord.observedEffects.reviews.length ? ['A single latest independent review cannot be selected from the retained observation times; all retained reviews remain separately attributed.'] : []),
766
790
  ...(!lineageComplete ? ['Provider-outcome time is withheld because the selected action origin lineage is incomplete.'] : []),
767
791
  ],
@@ -797,6 +821,7 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
797
821
  return {
798
822
  contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
799
823
  version: CASE_RESPONSE_REVIEW_INPUTS_VERSION,
824
+ sourceQualifications: caseSelectedEvidenceSourceLimitations(caseRecord.evidencePins, caseRecord.evidenceLinks, selectedEvidence.map(pin => pin.id)),
800
825
  profile: {
801
826
  id: profile.id,
802
827
  label: profile.label,
@@ -936,6 +961,9 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
936
961
  ? 'This packet is bound to explicit confirmations for the exact reviewed-input digest. It still requires deliberate manual use.'
937
962
  : 'This packet is a local draft with cautions and is not authorised for external use by WHOISleuth.',
938
963
  'WHOISleuth did not submit this packet or verify that any listed contact is monitored.',
964
+ ...reviewMaterial.sourceQualifications,
965
+ ...(selectedPacketAction(caseRecord, input)?.correction ? [CASE_DELIVERY_RECEIPT_LIMITATION,
966
+ 'This is a new correction or retraction request. It does not undo the original delivery, establish recipient acceptance or restoration, or replace independent recheck.'] : []),
939
967
  ...(!contacts.length ? ['No escalation contact was included.'] : []),
940
968
  ...(age.refreshRecommended ? ['The selected observation is over seven days old or appears to be in the future. Refresh evidence before submission.'] : []),
941
969
  ];