@slicedearth/whoisleuth-cli 2.5.1 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/DISCLOSURE +9 -6
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -814
  5. package/cli/artifact-validation/lookup-evidence.mjs +30 -3
  6. package/cli/artifact-validation/signed-review.mjs +2 -51
  7. package/cli/artifact-verify.mjs +41 -3
  8. package/cli/bulk-command-runner.mjs +4 -6
  9. package/cli/case-command.mjs +92 -16
  10. package/cli/case-pack.mjs +14 -3
  11. package/cli/ci-report.mjs +17 -5
  12. package/cli/command-definition.mjs +7 -6
  13. package/cli/discovery-scan-command-runner.mjs +4 -6
  14. package/cli/doctor.mjs +37 -3
  15. package/cli/domain-feed.mjs +61 -0
  16. package/cli/formatters/html.mjs +3 -0
  17. package/cli/formatters/json.mjs +9 -14
  18. package/cli/formatters/terminal-lookup.mjs +24 -1
  19. package/cli/history-command-definitions.mjs +1 -1
  20. package/cli/indicator-set-command.mjs +1 -2
  21. package/cli/intake-command.mjs +43 -3
  22. package/cli/investigation-artifacts.mjs +2 -3
  23. package/cli/investigation-package-review.mjs +2 -0
  24. package/cli/investigation-plan.mjs +15 -0
  25. package/cli/local-mmdb-review.mjs +27 -4
  26. package/cli/lookup-brief.mjs +11 -5
  27. package/cli/lookup-command-runner.mjs +21 -13
  28. package/cli/offline-evidence-review.mjs +51 -5
  29. package/cli/review-arguments.mjs +3 -2
  30. package/cli/review-command-definitions.mjs +23 -17
  31. package/cli/review-command-runner.mjs +6 -3
  32. package/cli/risk-calibration.mjs +6 -4
  33. package/cli/saved-lookup.mjs +22 -7
  34. package/cli/selected-input-worker.mjs +12 -2
  35. package/cli/source-reliability.mjs +12 -10
  36. package/cli/support-arguments.mjs +1 -1
  37. package/cli/support-command-definitions.mjs +5 -4
  38. package/cli/support-command-runner.mjs +9 -7
  39. package/cli/watchlist-review.mjs +43 -0
  40. package/cli/workflow-arguments.mjs +14 -0
  41. package/cli/workflow-command-definitions.mjs +27 -1
  42. package/cli/workflow-command-runner.mjs +32 -0
  43. package/docs/cli-reference.md +49 -11
  44. package/docs/cli.md +200 -26
  45. package/docs/contextual-reviews.md +38 -5
  46. package/docs/infrastructure-observations.md +135 -0
  47. package/docs/offline-intake-context.md +213 -0
  48. package/lib/credential-form-attribution.mjs +136 -0
  49. package/lib/credential-surface-profile.mjs +6 -2
  50. package/lib/dmarc-discovery.mjs +4 -2
  51. package/lib/dns-delegation-health.mjs +23 -7
  52. package/lib/dns-inheritance-review.mjs +14 -1
  53. package/lib/dns-intelligence.mjs +18 -4
  54. package/lib/domain-posture-analysis.mjs +53 -2
  55. package/lib/domain-posture-parsers.mjs +9 -0
  56. package/lib/domain-posture.mjs +29 -10
  57. package/lib/evidence-export-privacy.mjs +5 -2
  58. package/lib/evidence-export.mjs +10 -4
  59. package/lib/evidence-report.mjs +3 -1
  60. package/lib/favicon.mjs +13 -4
  61. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  62. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  63. package/lib/generated/retire-browser-catalog.mjs +178 -5
  64. package/lib/html-signals.mjs +6 -0
  65. package/lib/idn-confusables.mjs +5 -1
  66. package/lib/lookup-child-profile-contract.mjs +28 -11
  67. package/lib/lookup-collection-receipt.mjs +52 -0
  68. package/lib/lookup-diagnostics.mjs +2 -12
  69. package/lib/lookup-progress.mjs +29 -31
  70. package/lib/lookup-response-contract.mjs +10 -0
  71. package/lib/lookup-response.mjs +15 -14
  72. package/lib/lookup-source-progress.mjs +4 -2
  73. package/lib/lookup.mjs +33 -2
  74. package/lib/operation-budget.mjs +4 -0
  75. package/lib/rdap-capabilities.mjs +43 -9
  76. package/lib/registry-insights.mjs +17 -6
  77. package/lib/service-binding-dns.mjs +19 -2
  78. package/lib/sslbl-certificates.generated.mjs +142 -140
  79. package/lib/static-html-analysis.mjs +32 -4
  80. package/lib/technology-evidence-role.mjs +1 -51
  81. package/lib/tls-intelligence.mjs +30 -5
  82. package/lib/website-enrichment.mjs +39 -23
  83. package/lib/whois-authority.mjs +56 -1
  84. package/lib/whois-chain.mjs +14 -4
  85. package/lib/whois-common-formats.mjs +3 -1
  86. package/lib/whois-contacts.mjs +13 -2
  87. package/lib/whois-contracts.mjs +2 -0
  88. package/lib/whois-normalization.mjs +4 -4
  89. package/package.json +9 -7
  90. package/packages/analysis/bounded-json.mjs +12 -2
  91. package/packages/analysis/risk-scoring.mjs +12 -9
  92. package/packages/cases/case-attachment-model.mjs +36 -1
  93. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  94. package/packages/cases/case-containment-handoff.mjs +195 -0
  95. package/packages/cases/case-evidence-links.mjs +44 -0
  96. package/packages/cases/case-evidence-model.mjs +51 -7
  97. package/packages/cases/case-migration-model.mjs +9 -1
  98. package/packages/cases/case-object-coverage.mjs +119 -0
  99. package/packages/cases/case-packet-correction.mjs +106 -0
  100. package/packages/cases/case-recheck-model.mjs +35 -5
  101. package/packages/cases/case-record-operations.mjs +87 -9
  102. package/packages/cases/case-record-projection.mjs +43 -5
  103. package/packages/cases/case-record-version-input.mjs +23 -1
  104. package/packages/cases/case-report.mjs +35 -4
  105. package/packages/cases/case-requested-evidence.mjs +1 -1
  106. package/packages/cases/case-response-actions.mjs +57 -1
  107. package/packages/cases/case-response-model.mjs +7 -0
  108. package/packages/cases/case-response-object.mjs +119 -0
  109. package/packages/cases/case-response-outcomes.mjs +81 -14
  110. package/packages/cases/case-response-packet-render.mjs +22 -3
  111. package/packages/cases/case-response-packet-validation.mjs +880 -0
  112. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  113. package/packages/cases/case-response-packet.mjs +32 -17
  114. package/packages/cases/case-response-review-inputs.mjs +66 -8
  115. package/packages/cases/case-type-presentation.mjs +10 -0
  116. package/packages/cases/case-workflow-metadata.mjs +1 -17
  117. package/packages/comparison/capture-context.mjs +3 -2
  118. package/packages/contracts/analyst-interchange.mjs +7 -5
  119. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  120. package/packages/contracts/candidate-watch-review.mjs +54 -0
  121. package/packages/contracts/case-portability.mjs +292 -43
  122. package/packages/contracts/cli-command-semantics.mjs +3 -0
  123. package/packages/contracts/cli-doctor.mjs +4 -0
  124. package/packages/contracts/cli-lookup.mjs +57 -46
  125. package/packages/contracts/context-review.mjs +2 -1
  126. package/packages/contracts/external-observation-interchange.mjs +96 -28
  127. package/packages/contracts/lookup-evidence.mjs +13 -2
  128. package/packages/contracts/message-intake.mjs +7 -1
  129. package/packages/contracts/registry-insights.mjs +6 -0
  130. package/packages/contracts/risk-calibration.mjs +44 -29
  131. package/packages/contracts/schema-lifecycle.mjs +3 -1
  132. package/packages/contracts/web-capture.mjs +1 -1
  133. package/packages/contracts/workspace-portability.mjs +86 -16
  134. package/packages/evidence/collection-quality.mjs +1 -1
  135. package/packages/evidence/credential-form-attribution.mjs +60 -0
  136. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  137. package/packages/evidence/image-regions.mjs +15 -0
  138. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  139. package/packages/evidence/lookup-collection-review.mjs +74 -0
  140. package/packages/evidence/record-identity.mjs +60 -16
  141. package/packages/evidence/technology-evidence-role.mjs +51 -0
  142. package/packages/interchange/external-findings-import.mjs +40 -21
  143. package/packages/interchange/json-output.mjs +14 -0
  144. package/packages/interchange/managed-indicator-export.mjs +3 -2
  145. package/packages/interchange/managed-indicator-set.mjs +13 -0
  146. package/packages/investigation/acquisition-decision-packet.mjs +78 -0
  147. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  148. package/packages/investigation/context-review.mjs +3 -3
  149. package/packages/investigation/document-intake.mjs +9 -3
  150. package/packages/investigation/domain-history-review.mjs +50 -12
  151. package/packages/investigation/domain-transition-review.mjs +2 -0
  152. package/packages/investigation/infrastructure-observation.mjs +234 -0
  153. package/packages/investigation/intake-context.mjs +284 -0
  154. package/packages/investigation/intake-indicators.mjs +116 -0
  155. package/packages/investigation/intake-phones.mjs +155 -0
  156. package/packages/investigation/intake-report.mjs +2 -1
  157. package/packages/investigation/investigation-manifest.mjs +33 -7
  158. package/packages/investigation/link-intake.mjs +38 -8
  159. package/packages/investigation/message-intake.mjs +55 -15
  160. package/packages/monitoring/domain-feed.mjs +321 -0
  161. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  162. package/packages/workspace/brand-keyword-campaign.mjs +241 -0
  163. package/packages/workspace/brand-profile-model.mjs +46 -2
  164. package/packages/workspace/bulk-session-model.mjs +12 -0
  165. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  166. package/packages/workspace/detection-rule-model.mjs +10 -2
  167. package/packages/workspace/shortlist-model.mjs +10 -2
  168. package/packages/workspace/watchlist-history.mjs +31 -4
  169. package/packages/workspace/watchlist-store.mjs +136 -7
  170. package/third-party-notices.txt +30 -30
@@ -6,6 +6,8 @@ import { normalizeCase } from "./case-record-operations.mjs";
6
6
  import { normalizeCaseObjective } from "./case-incident-context.mjs";
7
7
  import { normalizeDomain } from "../evidence/domain-name.mjs";
8
8
  import { mergeImportedEvidenceHistory, normalizeEvidenceHistory } from "./case-evidence-model.mjs";
9
+ import { assertCaseCollectionReceiptAudience } from "./case-record-projection.mjs";
10
+ import { readCaseCheckpointReceipts, mergeCaseCheckpointReceipts } from "./case-checkpoint-receipts.mjs";
9
11
  import { CASE_REPORT_SCHEMA, PUBLIC_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
10
12
  import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
11
13
  import { readCaseAttachments, mergeCaseAttachments } from "./case-attachment-model.mjs";
@@ -242,6 +244,7 @@ function extractImportPatch(raw, importedVersion) {
242
244
  caseDomain: domain,
243
245
  }),
244
246
  evidencePins,
247
+ checkpointCollectionReceipts: readCaseCheckpointReceipts(record.checkpointCollectionReceipts, evidencePins, importedVersion),
245
248
  decisions,
246
249
  actions,
247
250
  assertions,
@@ -297,6 +300,7 @@ function caseFromPatch(patch, now) {
297
300
  source: patch.source ?? DEFAULT_SOURCE,
298
301
  evidenceHistory: patch.evidenceHistory,
299
302
  evidencePins: patch.evidencePins,
303
+ ...(patch.checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts: patch.checkpointCollectionReceipts }),
300
304
  decisions: patch.decisions,
301
305
  actions: patch.actions,
302
306
  assertions: patch.assertions,
@@ -327,6 +331,7 @@ function applyImportPatch(local, patch) {
327
331
  const historySelection = mergeImportedEvidenceHistory(local.evidenceHistory, patch.evidenceHistory, local.domain);
328
332
  const pinSelection = retainLocalAuthoredRecords(local.evidencePins, patch.evidencePins, MAX_CASE_EVIDENCE_PINS);
329
333
  const evidencePins = normalizeCaseEvidencePins(pinSelection.records, fallback);
334
+ const checkpointCollectionReceipts = mergeCaseCheckpointReceipts(local, patch, evidencePins);
330
335
  const pinIds = new Set(evidencePins.map((item) => item.id));
331
336
  const actions = mergeCaseActions(local.actions, patch.actions, fallback, pinIds);
332
337
  const assertionSelection = retainLocalAuthoredRecords(local.assertions, patch.assertions, MAX_CASE_ASSERTIONS);
@@ -351,8 +356,9 @@ function applyImportPatch(local, patch) {
351
356
  + assertionSelection.omitted
352
357
  + trailSelection.omitted
353
358
  + sightingSelection.omitted;
359
+ const { checkpointCollectionReceipts: _previousReceipts, ...localFields } = local;
354
360
  return { record: {
355
- ...local,
361
+ ...localFields,
356
362
  title: patch.title !== undefined && importNewer ? patch.title : local.title ?? '',
357
363
  status: patch.status !== undefined && importNewer ? patch.status : local.status,
358
364
  disposition: patch.disposition !== undefined && importNewer ? patch.disposition : local.disposition,
@@ -361,6 +367,7 @@ function applyImportPatch(local, patch) {
361
367
  source: patch.source !== undefined && importNewer ? patch.source : local.source,
362
368
  evidenceHistory: historySelection.records,
363
369
  evidencePins,
370
+ ...(checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts }),
364
371
  decisions,
365
372
  actions,
366
373
  assertions,
@@ -465,6 +472,7 @@ function caseCollectionImportEnvelope(importedRaw) {
465
472
  || caseTimestampOrNull(root.exportedAt) !== root.exportedAt) {
466
473
  throw new Error('The WHOISleuth CLI Case-pack envelope is invalid.');
467
474
  }
475
+ assertCaseCollectionReceiptAudience(root, audience);
468
476
  for (const reportValue of reports) {
469
477
  const report = objectRecord(reportValue);
470
478
  assertOnlyEnvelopeKeys(report, rootVersion === PUBLIC_CASE_SCHEMA_VERSION ? CLI_CASE_PACK_PUBLIC_REPORT_KEYS : CLI_CASE_PACK_REPORT_KEYS, 'CLI Case-pack report');
@@ -0,0 +1,119 @@
1
+ import { retainedCaseResponseObjects, sameCaseResponseObject, } from "./case-response-object.mjs";
2
+ import { correctionDelivery } from "./case-packet-correction.mjs";
3
+ export function caseIncidentObjects(record) {
4
+ const objects = new Map();
5
+ const explicit = retainedCaseResponseObjects(record);
6
+ for (const target of record.workflowMetadata?.incidentTargets ?? []) {
7
+ const matching = explicit.filter((object) => object.incidentTargetId === target.id && object.identifier === target.url);
8
+ for (const object of matching.length
9
+ ? matching
10
+ : [{ kind: 'other', identifier: target.url, incidentTargetId: target.id }])
11
+ objects.set(JSON.stringify(object), { target, object, targetRetained: true });
12
+ }
13
+ for (const object of explicit)
14
+ if (!objects.has(JSON.stringify(object)))
15
+ objects.set(JSON.stringify(object), {
16
+ object,
17
+ targetRetained: false,
18
+ target: {
19
+ id: object.incidentTargetId ?? `${object.kind}-${object.identifier}`,
20
+ url: object.identifier,
21
+ state: 'open',
22
+ createdAt: record.createdAt ?? '',
23
+ updatedAt: record.updatedAt ?? '',
24
+ },
25
+ });
26
+ return [...objects.values()];
27
+ }
28
+ export function buildCaseIncidentCoverage(record) {
29
+ return caseIncidentObjects(record).map(({ target, object, targetRetained }) => {
30
+ const contexts = (record.actions ?? [])
31
+ .map((action) => ({
32
+ action,
33
+ currentlyBound: Boolean(action.responseObjects?.some((bound) => sameCaseResponseObject(bound, object))),
34
+ events: action.history.filter((event) => event.responseObjects?.some((bound) => sameCaseResponseObject(bound, object))),
35
+ }))
36
+ .filter((context) => context.currentlyBound || context.events.length);
37
+ const actions = contexts
38
+ .filter((context) => context.currentlyBound)
39
+ .map((context) => context.action);
40
+ const responseContexts = contexts.map(({ action, currentlyBound, events }) => {
41
+ let correction = null;
42
+ if (currentlyBound && action.correction) {
43
+ let linkAvailable = false;
44
+ try {
45
+ linkAvailable = Boolean(correctionDelivery(record.actions ?? [], action));
46
+ }
47
+ catch {
48
+ /* Historical linkage can be unavailable; the draft is not re-authorised here. */
49
+ }
50
+ correction = {
51
+ purpose: action.correction.purpose,
52
+ originActionId: action.originActionId,
53
+ deliveryEventId: action.correction.deliveryEventId,
54
+ packetDigestSha256: action.correction.packetDigestSha256,
55
+ packetVersion: action.correction.packetVersion,
56
+ linkAvailable,
57
+ };
58
+ }
59
+ return {
60
+ actionId: action.id,
61
+ type: action.type,
62
+ recipient: action.recipient,
63
+ contactSource: action.contactSource,
64
+ routeObservedAt: action.routeObservedAt,
65
+ routeReviewAfter: action.routeReviewAfter,
66
+ contactLimitations: action.contactLimitations,
67
+ metadataUpdatedAt: action.metadataUpdatedAt,
68
+ currentState: action.state,
69
+ currentlyBound,
70
+ historyOmitted: action.historyOmitted,
71
+ historyLimitations: action.historyLimitations,
72
+ reviewEvents: events.filter((event) => event.sourceClass === 'analyst' &&
73
+ (event.nextState === 'reviewed' || event.nextState === 'authorised')),
74
+ // A repeated submitted-state update alone does not establish another delivery.
75
+ deliveryEvents: events.filter((event) => event.sourceClass === 'analyst' &&
76
+ event.nextState === 'submitted' &&
77
+ (event.previousState === 'authorised' || Boolean(event.packetReceipt))),
78
+ correction,
79
+ };
80
+ });
81
+ const responseEvents = contexts.flatMap(({ action, events }) => events
82
+ .filter((event) => event.providerOutcome || event.objectOutcome || event.nextState === 'acknowledged')
83
+ .map((event) => ({
84
+ actionId: action.id,
85
+ eventId: event.id,
86
+ state: event.nextState,
87
+ outcome: event.objectOutcome ?? event.providerOutcome,
88
+ occurredAt: event.occurredAt,
89
+ sourceClass: event.sourceClass,
90
+ applied: event.applied,
91
+ provenance: event.provenance,
92
+ reference: event.reference,
93
+ })));
94
+ const reviews = (record.observedEffects?.reviews ?? []).filter((review) => sameCaseResponseObject(review.responseObject, object));
95
+ const latestAt = Math.max(...reviews.map((review) => Date.parse(review.observedAt)));
96
+ const latest = reviews.filter((review) => Date.parse(review.observedAt) === latestAt);
97
+ return {
98
+ target,
99
+ targetRetained,
100
+ responseObject: object,
101
+ hostname: object.kind === 'domain' || object.kind === 'hostname'
102
+ ? object.identifier
103
+ : new URL(object.identifier).hostname,
104
+ actionCoverage: actions.length ? 'bound' : 'unknown',
105
+ actionIds: actions.map((action) => action.id),
106
+ responseContexts,
107
+ providerEvents: responseEvents.filter((event) => event.applied),
108
+ unappliedProviderEvents: responseEvents.filter((event) => !event.applied),
109
+ observationCoverage: !reviews.length
110
+ ? 'unknown'
111
+ : new Set(latest.map((review) => `${review.state}\u0000${review.objectOutcome ?? ''}`))
112
+ .size > 1
113
+ ? 'ambiguous'
114
+ : 'available',
115
+ reviews,
116
+ closures: (record.closures?.records ?? []).filter((closure) => sameCaseResponseObject(closure.responseObject, object)),
117
+ };
118
+ });
119
+ }
@@ -0,0 +1,106 @@
1
+ // Local delivery linkage, not authentication, external acceptance or nonrepudiation.
2
+ import { array, enumeration, exact, HEX_DIGEST_RE, iso, text } from "../evidence/artifact-structure.mjs";
3
+ import { assertWorkspaceInputGraph } from "../workspace/hostile-input.mjs";
4
+ import { MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS } from "../contracts/case-portability.mjs";
5
+ import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
6
+ import { readCaseResponseObjects, sameCaseResponseObject } from "./case-response-object.mjs";
7
+ import { RESPONSE_PACKET_PROFILE_IDS } from "./case-response-packet-vocabulary.mjs";
8
+ export const CASE_DELIVERY_RECEIPT_LIMITATION = 'This retained local receipt identifies canonical packet JSON and analyst-recorded delivery, not delivered message or PDF bytes, external receipt, factual truth, authorship authentication or provider acceptance.';
9
+ const id = (value, label) => {
10
+ const result = text(value, label, 64);
11
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(result))
12
+ throw new TypeError(`${label} is invalid.`);
13
+ return result;
14
+ };
15
+ const digest = (value) => {
16
+ if (typeof value !== 'string' || !HEX_DIGEST_RE.test(value))
17
+ throw new TypeError('Select an exact recorded packet digest.');
18
+ return value;
19
+ };
20
+ const packetVersion = (value) => {
21
+ if (!SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS.some(version => version === value))
22
+ throw new TypeError('The recorded packet version is unsupported.');
23
+ return value;
24
+ };
25
+ const statement = (value, label, required = true) => {
26
+ const result = text(value, label, MAX_RESPONSE_RATIONALE_LENGTH, !required).trim();
27
+ if (required && !result)
28
+ throw new TypeError(`${label} is required.`);
29
+ return result;
30
+ };
31
+ export function readCaseDeliveryPacketReceipt(value) {
32
+ if (value === undefined)
33
+ return undefined;
34
+ assertWorkspaceInputGraph(value, 'Delivery packet receipt');
35
+ const row = exact(value, ['version', 'caseId', 'actionId', 'target', 'packetDigestSha256', 'packetVersion', 'packetGeneratedAt', 'recipient', 'profile', 'responseObjects'], 'Delivery packet receipt');
36
+ if (row.version !== 1)
37
+ throw new TypeError('The delivery receipt version is unsupported.');
38
+ const target = text(row.target, 'Receipt target', 253);
39
+ if (!isValidAsciiHostname(target) || target !== target.toLowerCase())
40
+ throw new TypeError('The delivery receipt target is invalid.');
41
+ iso(row.packetGeneratedAt, 'Packet generation time');
42
+ const recipient = text(row.recipient, 'Receipt recipient', MAX_RESPONSE_RECIPIENT_LENGTH).trim();
43
+ if (!recipient)
44
+ throw new TypeError('The receipt recipient is required.');
45
+ const responseObjects = readCaseResponseObjects(row.responseObjects);
46
+ if (!responseObjects?.length)
47
+ throw new TypeError('The receipt requires an explicit nonempty delivered object scope.');
48
+ return Object.freeze({ version: 1, caseId: id(row.caseId, 'Receipt Case'), actionId: id(row.actionId, 'Receipt action'), target,
49
+ packetDigestSha256: digest(row.packetDigestSha256), packetVersion: packetVersion(row.packetVersion), packetGeneratedAt: row.packetGeneratedAt,
50
+ recipient, profile: enumeration(row.profile, RESPONSE_PACKET_PROFILE_IDS, 'Receipt audience'), responseObjects });
51
+ }
52
+ export function readCasePacketCorrection(value) {
53
+ if (value === undefined)
54
+ return undefined;
55
+ assertWorkspaceInputGraph(value, 'Packet correction');
56
+ const row = exact(value, ['version', 'purpose', 'deliveryEventId', 'packetDigestSha256', 'packetVersion', 'reason', 'previousStatement', 'correctedStatement', 'evidencePinIds', 'profile'], 'Packet correction');
57
+ if (row.version !== 1)
58
+ throw new TypeError('The correction version is unsupported.');
59
+ const purpose = enumeration(row.purpose, ['correction', 'retraction_request'], 'Correction purpose');
60
+ const pins = array(row.evidencePinIds, 'Correction evidence', MAX_DECISION_PIN_REFERENCES).map(value => id(value, 'Correction pin'));
61
+ if (!pins.length || new Set(pins).size !== pins.length)
62
+ throw new TypeError('Select distinct retained correction evidence pins.');
63
+ return Object.freeze({ version: 1, purpose, deliveryEventId: id(row.deliveryEventId, 'Original delivery event'),
64
+ packetDigestSha256: digest(row.packetDigestSha256), packetVersion: packetVersion(row.packetVersion),
65
+ reason: statement(row.reason, 'Correction reason'), previousStatement: statement(row.previousStatement, 'Previous statement'),
66
+ correctedStatement: statement(row.correctedStatement, 'Corrected statement', purpose === 'correction'),
67
+ evidencePinIds: Object.freeze(pins), profile: enumeration(row.profile, RESPONSE_PACKET_PROFILE_IDS, 'Correction audience') });
68
+ }
69
+ export function assertDeliveryPacketReceipt(receipt, event, actionId) {
70
+ if (!receipt)
71
+ return;
72
+ if (receipt.actionId !== actionId || !['authorised', 'submitted'].includes(event.previousState ?? '') || event.nextState !== 'submitted' || event.sourceClass !== 'analyst'
73
+ || event.reference !== `response-packet-sha256:${receipt.packetDigestSha256}`
74
+ || JSON.stringify(event.responseObjects ?? []) !== JSON.stringify(receipt.responseObjects)
75
+ || (event.occurredAt && Date.parse(event.occurredAt) < Date.parse(receipt.packetGeneratedAt)))
76
+ throw new TypeError('The packet receipt must match its exact analyst-recorded delivery and object scope.');
77
+ }
78
+ export function correctionDelivery(actions, action, context) {
79
+ const correction = action.correction;
80
+ if (!correction)
81
+ return null;
82
+ const original = actions.find(item => item.id === action.originActionId);
83
+ const event = original?.history.find(item => item.id === correction.deliveryEventId);
84
+ const receipt = event?.packetReceipt;
85
+ if (!original || !event?.applied || !receipt)
86
+ throw new TypeError('An exact-linked correction requires the retained receipt of the explicitly selected delivery. Legacy digest-only or omitted receipts cannot authorise it.');
87
+ assertDeliveryPacketReceipt(receipt, event, original.id);
88
+ if (receipt.recipient === '[redacted]' || action.recipient === '[redacted]' || receipt.recipient !== action.recipient
89
+ || original.recipient !== receipt.recipient || correction.profile !== receipt.profile
90
+ || correction.packetDigestSha256 !== receipt.packetDigestSha256 || correction.packetVersion !== receipt.packetVersion
91
+ || !action.responseObjects?.length || action.responseObjects.some(object => !receipt.responseObjects.some(previous => sameCaseResponseObject(previous, object))))
92
+ throw new TypeError('Correction recipient, version, digest, audience and object subset must match the selected delivered packet.');
93
+ if (context && (receipt.caseId !== context.caseId || receipt.target !== context.target || (context.profile && context.profile !== correction.profile)
94
+ || (context.selectedPinIds && correction.evidencePinIds.some(pin => !context.selectedPinIds.has(pin)))))
95
+ throw new TypeError('Review the current Case target, audience and every retained correction pin before preparation.');
96
+ return { original, event, receipt };
97
+ }
98
+ /** Called only with the exact completed packet; never uses a later editable draft. */
99
+ export function deliveryReceiptForPacket(packet) {
100
+ const action = packet.escalationHistory.find(action => action.actionId === packet.actionBinding.selectedActionId);
101
+ if (!action || !packet.recipientRoute || packet.authorisation.status !== 'authorised' || !action.responseObjects?.length)
102
+ throw new TypeError('A retained exact receipt requires an authorised action-bound packet with explicit object scope.');
103
+ return readCaseDeliveryPacketReceipt({ version: 1, caseId: packet.case.id, actionId: action.actionId, target: packet.case.domain,
104
+ packetDigestSha256: packet.integrity.digestSha256, packetVersion: packet.schemaVersion, packetGeneratedAt: packet.generatedAt,
105
+ recipient: packet.recipientRoute.contact, profile: packet.profile.id, responseObjects: action.responseObjects });
106
+ }
@@ -1,6 +1,7 @@
1
1
  import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
2
2
  import { INCIDENT_CASE_SCHEMA_VERSION, MAX_RESPONSE_RATIONALE_LENGTH, } from "../contracts/case-portability.mjs";
3
- import { enumeration, exact, text } from "../evidence/artifact-structure.mjs";
3
+ import { enumeration, exactOptional, text } from "../evidence/artifact-structure.mjs";
4
+ import { readCaseResponseObject, sameCaseResponseObject } from "./case-response-object.mjs";
4
5
  import { casePinHasCompleteObservation } from "./case-evidence-quality.mjs";
5
6
  export const CASE_RECHECK_CONDITIONS = Object.freeze({
6
7
  unknown: 'Not established',
@@ -13,7 +14,7 @@ function reference(value, label) {
13
14
  throw new TypeError(`${label} is invalid.`);
14
15
  return id;
15
16
  }
16
- function contextFields(item) {
17
+ function contextFields(item, sourceVersion) {
17
18
  const targetHostname = text(item.targetHostname, 'Recheck target hostname', 253);
18
19
  if (targetHostname !== targetHostname.toLowerCase() || !isValidAsciiHostname(targetHostname)) {
19
20
  throw new TypeError('Use one normalised hostname for the recheck target, without a path or query.');
@@ -21,10 +22,16 @@ function contextFields(item) {
21
22
  const conditions = text(item.conditions, 'Recheck comparison conditions', MAX_RESPONSE_RATIONALE_LENGTH);
22
23
  if (!conditions.trim())
23
24
  throw new TypeError('Describe the conditions needed to compare the observations.');
25
+ const responseObject = readCaseResponseObject(item.responseObject, sourceVersion);
26
+ if (responseObject && (responseObject.kind === 'domain' || responseObject.kind === 'hostname'
27
+ ? responseObject.identifier : new URL(responseObject.identifier).hostname) !== targetHostname) {
28
+ throw new TypeError('The recheck target hostname must match the exact object snapshot.');
29
+ }
24
30
  return Object.freeze({
25
31
  targetHostname,
26
32
  conditions,
27
33
  baselinePinId: item.baselinePinId === null ? null : reference(item.baselinePinId, 'Recheck baseline pin'),
34
+ ...(responseObject ? { responseObject } : {}),
28
35
  });
29
36
  }
30
37
  /** Optional current-format context; historical records have no invented question. */
@@ -33,19 +40,19 @@ export function readCaseRecheckContext(value, sourceVersion) {
33
40
  return undefined;
34
41
  if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
35
42
  throw new TypeError('This Case format cannot contain structured recheck questions.');
36
- return contextFields(exact(value, ['targetHostname', 'baselinePinId', 'conditions'], 'Recheck question context'));
43
+ return contextFields(exactOptional(value, ['targetHostname', 'baselinePinId', 'conditions'], ['responseObject'], 'Recheck question context'), sourceVersion);
37
44
  }
38
45
  export function readCaseRecheckAnswerContext(value, sourceVersion) {
39
46
  if (value === undefined)
40
47
  return undefined;
41
48
  if (sourceVersion != null && sourceVersion < INCIDENT_CASE_SCHEMA_VERSION)
42
49
  throw new TypeError('This Case format cannot contain structured recheck answers.');
43
- const item = exact(value, ['targetHostname', 'baselinePinId', 'conditions', 'questionId', 'question', 'conditionsMatch'], 'Recheck answer context');
50
+ const item = exactOptional(value, ['targetHostname', 'baselinePinId', 'conditions', 'questionId', 'question', 'conditionsMatch'], ['responseObject'], 'Recheck answer context');
44
51
  const question = text(item.question, 'Recheck question', MAX_RESPONSE_RATIONALE_LENGTH);
45
52
  if (!question.trim())
46
53
  throw new TypeError('A recheck answer requires its original question.');
47
54
  return Object.freeze({
48
- ...contextFields(item),
55
+ ...contextFields(item, sourceVersion),
49
56
  questionId: reference(item.questionId, 'Recheck question ID'),
50
57
  question,
51
58
  conditionsMatch: enumeration(item.conditionsMatch, Object.keys(CASE_RECHECK_CONDITIONS), 'Recheck condition comparison'),
@@ -92,6 +99,10 @@ const RECHECK_COMPARISON_MESSAGES = {
92
99
  later_observation_needed: 'A later source observation is needed; reviewing the baseline again is not a recheck.',
93
100
  baseline_target_mismatch: 'The baseline concerns a different hostname.',
94
101
  different_field_or_source: 'The observations use different fields or sources.',
102
+ missing_object_baseline: 'An object-specific baseline is required for this exact-object comparison.',
103
+ baseline_object_mismatch: 'The baseline does not explicitly concern this same object.',
104
+ current_object_mismatch: 'The current observation does not explicitly concern this same object.',
105
+ incomplete_object_baseline: 'The exact-object baseline must be a complete source observation.',
95
106
  };
96
107
  /** Admissibility depends on these reasons, not on presentation warnings. */
97
108
  export function caseRecheckComparisonBlockers(context, pins, current) {
@@ -102,6 +113,16 @@ export function caseRecheckComparisonBlockers(context, pins, current) {
102
113
  : 'unconfirmed_conditions');
103
114
  }
104
115
  const baseline = pins.find((pin) => pin.id === context.baselinePinId);
116
+ if (context.responseObject) {
117
+ if (!baseline)
118
+ blockers.push('missing_object_baseline');
119
+ else if (!sameCaseResponseObject(baseline.responseObject, context.responseObject))
120
+ blockers.push('baseline_object_mismatch');
121
+ if (baseline && !casePinHasCompleteObservation(baseline))
122
+ blockers.push('incomplete_object_baseline');
123
+ if (!current || !sameCaseResponseObject(current.responseObject, context.responseObject))
124
+ blockers.push('current_object_mismatch');
125
+ }
105
126
  if (context.baselinePinId && !baseline)
106
127
  blockers.push('missing_baseline');
107
128
  if (current) {
@@ -147,3 +168,12 @@ export function assertRecheckNonReproduction(state, context, completeness, pins,
147
168
  throw new Error('Not reproduced requires a complete observation under comparable conditions. Record unavailable or describe the limited observation instead.');
148
169
  }
149
170
  }
171
+ export const COMPARATIVE_CASE_OBJECT_OUTCOMES = Object.freeze(['removed', 'restricted', 'suspended', 'delisted', 'transferred', 'restored']);
172
+ /** Technical state changes need an exact-object baseline; procedural dispute is distinct. */
173
+ export function assertCaseObjectObservationOutcome(outcome, context, completeness, pins, current, observedAt) {
174
+ if (!outcome || !COMPARATIVE_CASE_OBJECT_OUTCOMES.includes(outcome))
175
+ return;
176
+ if (!context?.responseObject)
177
+ throw new TypeError('An independent object state change requires a saved exact-object baseline question and comparable evidence.');
178
+ assertRecheckNonReproduction('not_reproduced', context, completeness, pins, current, observedAt);
179
+ }
@@ -3,14 +3,17 @@
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";
9
11
  import { MAX_CASE_OBJECTIVE_LENGTH, MAX_RESPONSE_VALUE_LENGTH } from "../contracts/case-portability.mjs";
10
12
  import { caseRecordVersionInput } from "./case-record-version-input.mjs";
13
+ import { readCaseCheckpointReceipts, mergeCaseCheckpointReceipts } from "./case-checkpoint-receipts.mjs";
11
14
  import { readCaseAttachments } from "./case-attachment-model.mjs";
12
15
  import { readCaseEvidenceLinks, appendCaseEvidenceLink, withdrawCaseEvidenceLink } from "./case-evidence-links.mjs";
13
- import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
16
+ import { assertCurrentRecheckQuestion, assertRecheckNonReproduction, assertCaseObjectObservationOutcome, readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
14
17
  import { caseDispositionSupportsDefensiveResponse, caseStatusIsClosed, caseStatusOptionsForDirectEdit, caseStatusRequiresClosure, isReviewedCaseDisposition, } from "./case-record-decisions.mjs";
15
18
  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
19
  import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
@@ -64,8 +67,14 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
64
67
  const createdAt = existing ? existing.createdAt : caseTimestampOrNull(record.createdAt, sourceVersion) || now;
65
68
  const updatedAt = caseTimestampOrNull(record.updatedAt, sourceVersion) || createdAt;
66
69
  const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt, timestampOptions);
70
+ const checkpointCollectionReceipts = readCaseCheckpointReceipts(record.checkpointCollectionReceipts, evidencePins, sourceVersion);
67
71
  const pinIds = new Set(evidencePins.map((item) => item.id));
68
72
  const actions = normalizeCaseActions(record.actions, updatedAt, { ...timestampOptions, validEvidencePinIds: pinIds });
73
+ for (const action of actions)
74
+ for (const event of action.history)
75
+ if (event.packetReceipt
76
+ && (event.packetReceipt.caseId !== record.id || event.packetReceipt.target !== domain))
77
+ throw new TypeError('The packet receipt belongs to a different Case or target.');
69
78
  const assertions = normalizeCaseAssertions(record.assertions, updatedAt, pinIds, timestampOptions);
70
79
  const sightings = normalizeCaseSightings(record.sightings, updatedAt, pinIds, timestampOptions);
71
80
  const sightingIds = new Set(sightings.map((item) => item.id));
@@ -80,7 +89,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
80
89
  domain,
81
90
  title: normalizeCaseObjective(record.title),
82
91
  status: caseStatusRequiresClosure(normalizedStatus)
83
- && closures.records.length === 0 && !closures.preV13HistoryUnavailable
92
+ && !closures.records.some(closure => closure.responseObject === undefined) && !closures.preV13HistoryUnavailable
84
93
  ? 'reviewing'
85
94
  : normalizedStatus,
86
95
  disposition: normalizeDisposition(record.disposition),
@@ -91,6 +100,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
91
100
  source: normalizeSource(record.source),
92
101
  evidenceHistory: normalizeCaseEvidence(record, domain, createdAt, updatedAt, now, sourceVersion),
93
102
  evidencePins,
103
+ ...(checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts }),
94
104
  decisions: normalizeCaseDecisions(record.decisions, updatedAt, pinIds, timestampOptions),
95
105
  actions,
96
106
  assertions,
@@ -117,6 +127,13 @@ export function createCase(input, nowIso) {
117
127
  throw new Error('A valid domain is required to open a case.');
118
128
  const noteBody = normalizeNoteBody(input.note);
119
129
  const source = normalizeSource(input.source);
130
+ const workflowMetadata = updateCaseWorkflowMetadata(emptyCaseWorkflowMetadata(), input, domain, now);
131
+ const scopedCase = { domain, workflowMetadata };
132
+ for (const raw of [input.evidencePin, ...(Array.isArray(input.evidencePins) ? input.evidencePins : []), input.observedEffectReview, input.closure])
133
+ assertCaseResponseObject(readCaseResponseObject(objectRecord(raw).responseObject), scopedCase);
134
+ for (const object of readCaseResponseObjects(objectRecord(input.action).responseObjects) ?? [])
135
+ assertCaseResponseObject(object, scopedCase);
136
+ assertCaseResponseObject(readCaseResponseObject(objectRecord(objectRecord(input.assertion).recheck).responseObject), scopedCase);
120
137
  const evidencePins = input.evidencePins !== undefined
121
138
  ? appendCaseEvidencePins([], input.evidencePins, now)
122
139
  : input.evidencePin !== undefined
@@ -126,6 +143,7 @@ export function createCase(input, nowIso) {
126
143
  const actions = input.action !== undefined
127
144
  ? appendCaseAction([], input.action, now)
128
145
  : [];
146
+ const checkpointCollectionReceipts = readCaseCheckpointReceipts(input.checkpointCollectionReceipts, evidencePins);
129
147
  const assertions = input.assertion !== undefined
130
148
  ? appendCaseAssertion([], input.assertion, now)
131
149
  : [];
@@ -137,13 +155,19 @@ export function createCase(input, nowIso) {
137
155
  ? appendCaseObservedEffectReview(normalizeCaseObservedEffectHistory(undefined, now), input.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)))
138
156
  : normalizeCaseObservedEffectHistory(undefined, now);
139
157
  for (const review of observedEffects.reviews) {
158
+ const pin = evidencePins.find(item => item.id === review.evidencePinId);
159
+ if (review.responseObject && pin && !sameCaseResponseObject(review.responseObject, pin.responseObject))
160
+ throw new TypeError('The selected observation evidence does not explicitly concern this exact object.');
161
+ if (review.responseObject && review.state === 'not_reproduced' && !review.recheck)
162
+ throw new TypeError('Object-specific non-reproduction requires a saved baseline question and comparable evidence.');
163
+ assertCaseObjectObservationOutcome(review.objectOutcome, review.recheck, review.completeness, evidencePins, pin, review.observedAt);
140
164
  if (!review.recheck)
141
165
  continue;
142
166
  assertCurrentRecheckQuestion(review.recheck, assertions);
143
167
  assertRecheckNonReproduction(review.state, review.recheck, review.completeness, evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), review.observedAt);
144
168
  }
145
169
  const closures = input.closure !== undefined
146
- ? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
170
+ ? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions, evidencePins)
147
171
  : normalizeCaseClosureHistory(undefined, now);
148
172
  if (caseStatusRequiresClosure(normalizeStatus(input.status)) && input.closure === undefined) {
149
173
  throw new Error('Opening a resolved case requires a deliberate closure reason and its linked review context.');
@@ -152,12 +176,12 @@ export function createCase(input, nowIso) {
152
176
  id: makeId(),
153
177
  domain,
154
178
  title: normalizeCaseObjective(input.title),
155
- status: input.closure !== undefined ? 'resolved' : normalizeStatus(input.status),
179
+ status: input.closure !== undefined && objectRecord(input.closure).responseObject === undefined ? 'resolved' : normalizeStatus(input.status),
156
180
  disposition: normalizeDisposition(input.disposition),
157
181
  reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
158
182
  brandProfileIds: input.brandProfileIds === undefined ? [] : assertCaseBrandProfileIds(input.brandProfileIds),
159
183
  tags: normalizeTags(input.tags),
160
- workflowMetadata: updateCaseWorkflowMetadata(emptyCaseWorkflowMetadata(), input, domain, now),
184
+ workflowMetadata,
161
185
  notes: noteBody ? [{ id: makeId(), body: noteBody, createdAt: now }] : [],
162
186
  source,
163
187
  evidenceHistory: normalizeEvidenceHistory(input.evidence ? [input.evidence] : [], {
@@ -166,6 +190,7 @@ export function createCase(input, nowIso) {
166
190
  caseDomain: domain,
167
191
  }),
168
192
  evidencePins,
193
+ ...(checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts }),
169
194
  decisions: input.decision !== undefined
170
195
  ? appendCaseDecision([], input.decision, now)
171
196
  : [],
@@ -246,6 +271,21 @@ export function updateCase(cases, id, patch, nowIso) {
246
271
  const current = cases[index];
247
272
  if (!current)
248
273
  throw new Error('That case no longer exists.');
274
+ if (patch.expectedResponseContext !== undefined && patch.expectedResponseContext !== JSON.stringify(current))
275
+ throw new TypeError('The Case response context changed after preview. Review the current delivery, scope and evidence before creating this draft.');
276
+ const workflowMetadata = updateCaseWorkflowMetadata(current.workflowMetadata
277
+ ?? readCaseWorkflowFields(current, current.domain, current.assertions).workflowMetadata, patch, current.domain, now);
278
+ const scopedCase = { ...current, workflowMetadata };
279
+ for (const raw of [patch.evidencePin, ...(Array.isArray(patch.evidencePins) ? patch.evidencePins : []), patch.observedEffectReview, patch.closure]) {
280
+ assertCaseResponseObject(readCaseResponseObject(objectRecord(raw).responseObject), scopedCase);
281
+ }
282
+ for (const raw of [patch.action, patch.actionUpdate, objectRecord(patch.actionUpdate).transition]) {
283
+ for (const object of readCaseResponseObjects(objectRecord(raw).responseObjects) ?? [])
284
+ assertCaseResponseObject(object, scopedCase);
285
+ }
286
+ for (const raw of [patch.assertion, patch.assertionUpdate, patch.observedEffectReview]) {
287
+ assertCaseResponseObject(readCaseResponseObject(objectRecord(objectRecord(raw).recheck).responseObject), scopedCase);
288
+ }
249
289
  if (patch.evidenceLink !== undefined && patch.evidenceLinkWithdrawal !== undefined)
250
290
  throw new TypeError('Record or withdraw one evidence relationship at a time.');
251
291
  if (patch.title !== undefined && patch.expectedTitle !== undefined && patch.expectedTitle !== (current.title ?? '')) {
@@ -283,7 +323,23 @@ export function updateCase(cases, id, patch, nowIso) {
283
323
  if (patch.evidencePin !== undefined) {
284
324
  evidencePins = appendCaseEvidencePin(evidencePins, patch.evidencePin, now);
285
325
  }
326
+ const previousPinIds = new Set(current.evidencePins.map(pin => pin.id));
327
+ const checkpointCollectionReceipts = mergeCaseCheckpointReceipts(current, {
328
+ evidencePins: evidencePins.filter(pin => !previousPinIds.has(pin.id)),
329
+ checkpointCollectionReceipts: patch.checkpointCollectionReceipts,
330
+ }, evidencePins);
286
331
  const pinIds = new Set(evidencePins.map((item) => item.id));
332
+ const correctionInput = patch.action !== undefined ? objectRecord(patch.action) : patch.actionUpdate !== undefined
333
+ ? { ...current.actions.find(action => action.id === objectRecord(patch.actionUpdate).id), ...objectRecord(patch.actionUpdate) } : null;
334
+ if (correctionInput?.correction !== undefined)
335
+ correctionDelivery(current.actions, {
336
+ originActionId: typeof correctionInput.originActionId === 'string' ? correctionInput.originActionId : null,
337
+ recipient: typeof correctionInput.recipient === 'string' ? correctionInput.recipient : '',
338
+ responseObjects: readCaseResponseObjects(correctionInput.responseObjects), correction: readCasePacketCorrection(correctionInput.correction),
339
+ }, { caseId: current.id, target: current.domain, selectedPinIds: pinIds });
340
+ const transitionReceipt = readCaseDeliveryPacketReceipt(objectRecord(objectRecord(patch.actionUpdate).transition).packetReceipt);
341
+ if (transitionReceipt && (transitionReceipt.caseId !== current.id || transitionReceipt.target !== current.domain))
342
+ throw new TypeError('The packet receipt belongs to a different Case or target.');
287
343
  let decisions = current.decisions;
288
344
  if (patch.decision !== undefined) {
289
345
  decisions = appendCaseDecision(current.decisions, patch.decision, now, pinIds);
@@ -295,6 +351,16 @@ export function updateCase(cases, id, patch, nowIso) {
295
351
  if (patch.actionUpdate !== undefined) {
296
352
  actions = updateCaseAction(actions, patch.actionUpdate, now, pinIds);
297
353
  }
354
+ if (correctionInput?.correction !== undefined || transitionReceipt) {
355
+ for (const original of current.actions) {
356
+ const retained = actions.find(action => action.id === original.id);
357
+ if (!retained || original.history.some(event => !retained.history.some(next => JSON.stringify(next) === JSON.stringify(event)))
358
+ || (patch.action !== undefined && JSON.stringify(retained) !== JSON.stringify(original)))
359
+ throw new TypeError('This linked operation would omit or change historical delivery records within the Case bounds. No correction or receipt was retained.');
360
+ }
361
+ if (transitionReceipt && !actions.find(action => action.id === transitionReceipt.actionId)?.history.some(event => JSON.stringify(event.packetReceipt) === JSON.stringify(transitionReceipt)))
362
+ throw new TypeError('The exact delivery receipt exceeds the retained history bounds. No delivery was recorded.');
363
+ }
298
364
  let assertions = current.assertions;
299
365
  if (patch.assertion !== undefined) {
300
366
  assertions = appendCaseAssertion(current.assertions, patch.assertion, now, pinIds);
@@ -326,22 +392,28 @@ export function updateCase(cases, id, patch, nowIso) {
326
392
  assertCurrentRecheckQuestion(context, assertions);
327
393
  assertRecheckNonReproduction(review.state, context, String(review.completeness), evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), typeof review.observedAt === 'string' ? review.observedAt : null);
328
394
  }
395
+ const responseObject = readCaseResponseObject(review.responseObject);
396
+ const pin = evidencePins.find(item => item.id === review.evidencePinId);
397
+ assertCaseObjectObservationOutcome(review.objectOutcome, context, String(review.completeness), evidencePins, pin, typeof review.observedAt === 'string' ? review.observedAt : null);
398
+ if (responseObject && pin && !sameCaseResponseObject(responseObject, pin.responseObject))
399
+ throw new TypeError('The selected observation evidence does not explicitly concern this exact object.');
400
+ if (responseObject && review.state === 'not_reproduced' && !context)
401
+ throw new TypeError('Object-specific non-reproduction requires a saved baseline question and comparable evidence.');
329
402
  observedEffects = appendCaseObservedEffectReview(observedEffects, patch.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)));
330
403
  }
331
404
  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
405
  if (patch.closure !== undefined) {
333
- closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions);
406
+ closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions, evidencePins);
334
407
  }
335
408
  if (caseStatusRequiresClosure(patch.status) && patch.closure === undefined) {
336
409
  throw new Error('Resolve this case through the deliberate closure review so the reason and evidence state remain explicit.');
337
410
  }
338
411
  const record = {
339
412
  ...current,
340
- workflowMetadata: updateCaseWorkflowMetadata(current.workflowMetadata
341
- ?? readCaseWorkflowFields(current, current.domain, current.assertions).workflowMetadata, patch, current.domain, now),
413
+ workflowMetadata,
342
414
  title: patch.title === undefined ? current.title ?? '' : normalizeCaseObjective(patch.title),
343
415
  status: patch.closure !== undefined
344
- ? 'resolved'
416
+ ? objectRecord(patch.closure).responseObject !== undefined ? current.status : 'resolved'
345
417
  : patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
346
418
  disposition: patch.disposition !== undefined ? normalizeDisposition(patch.disposition) : current.disposition,
347
419
  reviewReasonCode: patch.reviewReasonCode !== undefined
@@ -354,6 +426,7 @@ export function updateCase(cases, id, patch, nowIso) {
354
426
  source,
355
427
  evidenceHistory,
356
428
  evidencePins,
429
+ ...(checkpointCollectionReceipts === undefined ? {} : { checkpointCollectionReceipts }),
357
430
  ...((patch.evidenceLink !== undefined || patch.evidenceLinkWithdrawal !== undefined) ? {
358
431
  evidenceLinks: patch.evidenceLink !== undefined
359
432
  ? appendCaseEvidenceLink(current.evidenceLinks ?? [], patch.evidenceLink, evidencePins, now)
@@ -371,6 +444,8 @@ export function updateCase(cases, id, patch, nowIso) {
371
444
  updatedAt: now,
372
445
  };
373
446
  const next = [...cases];
447
+ if (checkpointCollectionReceipts === undefined)
448
+ delete record.checkpointCollectionReceipts;
374
449
  next[index] = record;
375
450
  return { cases: next, record };
376
451
  }
@@ -504,6 +579,7 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
504
579
  observedAt: input.observedAt,
505
580
  collectionDepth: input.collectionDepth,
506
581
  observationHostname: input.observationHostname,
582
+ ...(input.responseObject ? { responseObject: input.responseObject } : {}),
507
583
  completeness: input.completeness,
508
584
  truncated: input.comparisonTruncated === true
509
585
  || (typeof input.comparisonSummary === 'string' && input.comparisonSummary.length > MAX_RESPONSE_VALUE_LENGTH),
@@ -524,6 +600,8 @@ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
524
600
  followUpAt: input.followUpAt,
525
601
  limitations: input.limitations,
526
602
  ...(input.recheck ? { recheck: input.recheck } : {}),
603
+ ...(input.responseObject ? { responseObject: input.responseObject } : {}),
604
+ ...(input.objectOutcome ? { objectOutcome: input.objectOutcome } : {}),
527
605
  },
528
606
  }, now);
529
607
  }