@slicedearth/whoisleuth-cli 2.6.0 → 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 (84) hide show
  1. package/DISCLOSURE +8 -5
  2. package/NOTICE +3 -2
  3. package/README.md +60 -67
  4. package/cli/artifact-validation/case-response.mjs +6 -857
  5. package/cli/artifact-validation/lookup-evidence.mjs +15 -2
  6. package/cli/artifact-verify.mjs +32 -1
  7. package/cli/case-command.mjs +17 -17
  8. package/cli/case-pack.mjs +7 -1
  9. package/cli/ci-report.mjs +17 -5
  10. package/cli/command-definition.mjs +5 -5
  11. package/cli/doctor.mjs +37 -3
  12. package/cli/formatters/html.mjs +3 -0
  13. package/cli/formatters/json.mjs +8 -1
  14. package/cli/formatters/terminal-lookup.mjs +24 -1
  15. package/cli/history-command-definitions.mjs +1 -1
  16. package/cli/investigation-plan.mjs +15 -0
  17. package/cli/lookup-brief.mjs +11 -5
  18. package/cli/lookup-command-runner.mjs +20 -13
  19. package/cli/review-command-definitions.mjs +11 -11
  20. package/cli/saved-lookup.mjs +22 -7
  21. package/cli/source-reliability.mjs +12 -10
  22. package/cli/support-arguments.mjs +1 -1
  23. package/cli/support-command-definitions.mjs +5 -4
  24. package/cli/support-command-runner.mjs +6 -4
  25. package/cli/workflow-command-definitions.mjs +2 -1
  26. package/docs/cli-reference.md +49 -11
  27. package/docs/cli.md +109 -32
  28. package/docs/contextual-reviews.md +10 -0
  29. package/docs/infrastructure-observations.md +9 -0
  30. package/lib/dmarc-discovery.mjs +4 -2
  31. package/lib/dns-inheritance-review.mjs +14 -1
  32. package/lib/evidence-export.mjs +10 -4
  33. package/lib/evidence-report.mjs +2 -1
  34. package/lib/generated/cisa-kev-catalog.mjs +17 -4
  35. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  36. package/lib/generated/retire-browser-catalog.mjs +178 -5
  37. package/lib/idn-confusables.mjs +5 -1
  38. package/lib/lookup-collection-receipt.mjs +52 -0
  39. package/lib/lookup-diagnostics.mjs +2 -12
  40. package/lib/lookup-progress.mjs +29 -31
  41. package/lib/lookup-response-contract.mjs +9 -0
  42. package/lib/lookup-response.mjs +15 -14
  43. package/lib/lookup-source-progress.mjs +4 -2
  44. package/lib/lookup.mjs +33 -2
  45. package/lib/rdap-capabilities.mjs +43 -9
  46. package/lib/registry-insights.mjs +17 -6
  47. package/lib/sslbl-certificates.generated.mjs +143 -142
  48. package/lib/whois-authority.mjs +56 -1
  49. package/lib/whois-chain.mjs +2 -1
  50. package/lib/whois-contracts.mjs +2 -0
  51. package/lib/whois-normalization.mjs +4 -4
  52. package/package.json +6 -6
  53. package/packages/analysis/bounded-json.mjs +12 -2
  54. package/packages/cases/case-attachment-model.mjs +36 -1
  55. package/packages/cases/case-checkpoint-receipts.mjs +68 -0
  56. package/packages/cases/case-evidence-links.mjs +28 -0
  57. package/packages/cases/case-evidence-model.mjs +39 -6
  58. package/packages/cases/case-migration-model.mjs +9 -1
  59. package/packages/cases/case-object-coverage.mjs +106 -20
  60. package/packages/cases/case-record-operations.mjs +13 -0
  61. package/packages/cases/case-record-projection.mjs +28 -2
  62. package/packages/cases/case-report.mjs +5 -4
  63. package/packages/cases/case-response-packet-validation.mjs +880 -0
  64. package/packages/cases/case-response-packet-vocabulary.mjs +12 -1
  65. package/packages/cases/case-response-packet.mjs +2 -15
  66. package/packages/cases/case-response-review-inputs.mjs +18 -1
  67. package/packages/cases/case-type-presentation.mjs +10 -0
  68. package/packages/cases/case-workflow-metadata.mjs +0 -8
  69. package/packages/comparison/capture-context.mjs +3 -2
  70. package/packages/contracts/analyst-review-state-contract.mjs +1 -1
  71. package/packages/contracts/case-portability.mjs +59 -28
  72. package/packages/contracts/cli-doctor.mjs +4 -0
  73. package/packages/contracts/cli-lookup.mjs +57 -46
  74. package/packages/contracts/lookup-evidence.mjs +12 -2
  75. package/packages/contracts/registry-insights.mjs +6 -0
  76. package/packages/evidence/dmarc-policy-context.mjs +80 -0
  77. package/packages/evidence/lookup-collection-receipt.mjs +140 -0
  78. package/packages/evidence/lookup-collection-review.mjs +74 -0
  79. package/packages/interchange/external-findings-import.mjs +8 -4
  80. package/packages/investigation/bulk-mail-exposure.mjs +2 -2
  81. package/packages/investigation/domain-history-review.mjs +7 -1
  82. package/packages/workspace/brand-keyword-campaign.mjs +11 -1
  83. package/packages/workspace/bulk-session-model.mjs +8 -0
  84. package/third-party-notices.txt +29 -29
@@ -0,0 +1,880 @@
1
+ import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertPacketAmendmentSelection } from "./case-requested-evidence.mjs";
2
+ import { readCaseDeliveryPacketReceipt, readCasePacketCorrection, assertDeliveryPacketReceipt, correctionDelivery } from "./case-packet-correction.mjs";
3
+ import { CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION } from "./case-evidence-links.mjs";
4
+ import { CASE_RESPONSE_PACKET_SCHEMA, OBJECT_RESPONSE_CASE_PACKET_VERSION, OBJECT_RESPONSE_CASE_REVIEW_INPUTS_VERSION } from "../contracts/case-portability.mjs";
5
+ import { assertBoundedJsonStructure } from "../analysis/bounded-json.mjs";
6
+ import { assertCaseResponseRecipientAction } from "./case-response-review-inputs.mjs";
7
+ import { canonicalArtifactJsonV2, sha256ArtifactBytes } from "../evidence/artifact-integrity.mjs";
8
+ import { EVIDENCE_FOLLOW_UP_CASE_RESPONSE_PACKET_VERSION, EVIDENCE_FOLLOW_UP_CASE_RESPONSE_REVIEW_INPUTS_VERSION } from "../contracts/case-portability.mjs";
9
+ import { readCaseResponseObject, readCaseResponseObjects, readCaseResponseObjectOutcome, assertCaseObjectOutcome } from "./case-response-object.mjs";
10
+ import { CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION, PUBLIC_CASE_RESPONSE_PACKET_VERSION, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION, LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION, PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSIVE_URLS, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_CONTACTS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_SELECTED_EVIDENCE, RESPONSE_AUTHORISATION_CONFIRMATION_IDS, RESPONSE_PACKET_PROFILES, RESPONSE_CONTACT_KINDS, RESPONSE_PACKET_PROFILE_IDS, RESPONSE_READINESS_ROW_IDS, RESPONSE_READINESS_STATES, RESPONSE_ROUTE_STALE_AFTER_DAYS, verifyCaseResponsePacketIntegrity, } from "./case-response-packet.mjs";
11
+ import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, CASE_PROVIDER_OUTCOMES, isLegalCaseActionTransition, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RECIPIENT_LENGTH, } from "./case-response-model.mjs";
12
+ import { HEX_DIGEST_RE, array, boolean, domain, enumeration, exact, exactOptional, fail, integer, iso, optionalText, sameValues, strings, text, } from "../evidence/artifact-structure.mjs";
13
+ export function assertCaseResponseReviewedInputDigest(check, actual) {
14
+ if (check.expectedDigestSha256 !== actual)
15
+ fail('Case-response reviewed-input digest');
16
+ }
17
+ const CASE_RESPONSE_PREFLIGHT_IDS_V6 = [
18
+ 'required_incident_fields',
19
+ 'evidence_pins',
20
+ 'analyst_decision',
21
+ 'recipient_route',
22
+ 'profile_recipient',
23
+ 'case_disposition',
24
+ 'evidence_freshness',
25
+ 'contradictory_evidence',
26
+ 'action_tracking',
27
+ ];
28
+ const CASE_RESPONSE_PREFLIGHT_IDS_V7 = CASE_RESPONSE_PREFLIGHT_IDS_V6;
29
+ const CASE_RESPONSE_PREFLIGHT_IDS_V8 = [
30
+ ...CASE_RESPONSE_PREFLIGHT_IDS_V7.slice(0, -1),
31
+ 'packet_action',
32
+ 'action_tracking',
33
+ ];
34
+ const PUBLIC_CASE_ACTION_STATES = ['planned', 'ready_for_review', 'submitted', 'acknowledged', 'resolved', 'closed'];
35
+ const PRE_PLATFORM_PROFILE_IDS = RESPONSE_PACKET_PROFILE_IDS.filter((id) => id !== 'application_platform');
36
+ const PRE_PLATFORM_CONTACT_KINDS = RESPONSE_CONTACT_KINDS.filter((kind) => kind !== 'application_platform');
37
+ const PRE_PLATFORM_ACTION_TYPES = CASE_ACTION_TYPES.filter((type) => type !== 'platform_report');
38
+ function legacyProfileRedactions(profileId) {
39
+ if (profileId === 'registrar' || profileId === 'registry') {
40
+ return ['Remove unnecessary personal registration data', 'Remove credentials, tokens, and URL fragments'];
41
+ }
42
+ if (profileId === 'network_hosting')
43
+ return ['Remove credentials, query secrets, fragments, cookies, and unrelated contacts'];
44
+ if (profileId === 'security_contact')
45
+ return ['Remove credentials, tokens, personal data, and unrelated identifiers'];
46
+ if (profileId === 'browser_blocklist')
47
+ return ['Remove credentials, tokens, URL fragments, and unrelated contacts'];
48
+ return ['Remove credentials, tokens, and personal data not required for the internal decision'];
49
+ }
50
+ function validatePublicActionSummary(value, label) {
51
+ const summary = exact(value, ['total', 'active', 'submitted', 'acknowledged', 'resolved', 'closed', 'overdue', 'followUpDue', 'withOutcome', 'latestOutcomes'], label);
52
+ const total = integer(summary.total, label, 0, MAX_CASE_ACTIONS);
53
+ for (const key of ['active', 'submitted', 'acknowledged', 'resolved', 'closed', 'overdue', 'followUpDue', 'withOutcome'])
54
+ integer(summary[key], label, 0, total);
55
+ if (Number(summary.resolved) + Number(summary.closed) + Number(summary.active) !== total)
56
+ fail(label);
57
+ const outcomes = array(summary.latestOutcomes, label, 5);
58
+ for (const candidate of outcomes) {
59
+ const outcome = exact(candidate, ['actionId', 'recipient', 'state', 'outcome', 'updatedAt'], label);
60
+ text(outcome.actionId, label, 64);
61
+ text(outcome.recipient, label, 320);
62
+ enumeration(outcome.state, PUBLIC_CASE_ACTION_STATES, label);
63
+ text(outcome.outcome, label, 2_000);
64
+ iso(outcome.updatedAt, label);
65
+ }
66
+ }
67
+ function expectedObservationAge(observedAt, generatedAt) {
68
+ const ageSeconds = Math.floor((Date.parse(generatedAt) - Date.parse(observedAt)) / 1_000);
69
+ if (ageSeconds < -300)
70
+ return { ageSeconds, band: 'future_or_clock_skew', refreshRecommended: true };
71
+ if (ageSeconds < 86_400)
72
+ return { ageSeconds: Math.max(0, ageSeconds), band: 'under_24_hours', refreshRecommended: false };
73
+ if (ageSeconds <= 604_800)
74
+ return { ageSeconds, band: 'one_to_seven_days', refreshRecommended: false };
75
+ return { ageSeconds, band: 'over_seven_days', refreshRecommended: true };
76
+ }
77
+ function validateCaseResponsePacketV6(value) {
78
+ const root = exact(value, ['schema', 'schemaVersion', 'generatedAt', 'reviewRequired', 'submissionPerformed', 'profile', 'case', 'incident', 'contacts', 'preflight', 'escalationHistory', 'provenance', 'integrity'], 'Case-response packet');
79
+ if (root.schemaVersion !== PUBLIC_CASE_RESPONSE_PACKET_VERSION)
80
+ fail('Case-response packet v6');
81
+ iso(root.generatedAt, 'Case-response packet generatedAt');
82
+ if (root.reviewRequired !== true || root.submissionPerformed !== false)
83
+ fail('Case-response packet review state');
84
+ const profile = exact(root.profile, ['id', 'label', 'audience', 'subject', 'checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'], 'Case-response profile');
85
+ const profileId = enumeration(profile.id, PRE_PLATFORM_PROFILE_IDS, 'Case-response profile id');
86
+ text(profile.label, 'Case-response profile label', 200);
87
+ text(profile.audience, 'Case-response profile audience', 300);
88
+ text(profile.subject, 'Case-response profile subject', 500);
89
+ for (const key of ['checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'])
90
+ strings(profile[key], `Case-response profile ${key}`, 24, 500);
91
+ const caseRecord = exact(root.case, ['id', 'domain', 'status', 'disposition', 'updatedAt'], 'Case-response case');
92
+ text(caseRecord.id, 'Case-response case id', 128);
93
+ domain(caseRecord.domain, 'Case-response case domain');
94
+ text(caseRecord.status, 'Case-response case status', 80);
95
+ text(caseRecord.disposition, 'Case-response case disposition', 80);
96
+ iso(caseRecord.updatedAt, 'Case-response case updatedAt');
97
+ const incident = exact(root.incident, ['category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt'], 'Case-response incident');
98
+ text(incident.category, 'Case-response category', 80);
99
+ text(incident.affectedParty, 'Case-response affected party', 200);
100
+ const urls = strings(incident.abusiveUrls, 'Case-response abusive URLs', MAX_ABUSIVE_URLS, 2_048);
101
+ if (!urls.length || urls.some((item) => { try {
102
+ return !['http:', 'https:'].includes(new URL(item).protocol);
103
+ }
104
+ catch {
105
+ return true;
106
+ } }))
107
+ fail('Case-response abusive URLs');
108
+ text(incident.observedHarm, 'Case-response observed harm', 2_000);
109
+ iso(incident.observedAt, 'Case-response observedAt');
110
+ const selectedProfile = RESPONSE_PACKET_PROFILES.find((candidate) => candidate.id === profileId);
111
+ if (!selectedProfile
112
+ || profile.label !== selectedProfile.label
113
+ || profile.audience !== selectedProfile.audience
114
+ || profile.subject !== `${selectedProfile.subjectPrefix}: ${caseRecord.domain} (${incident.category})`
115
+ || !sameValues(profile.checklist, selectedProfile.checklist)
116
+ || !sameValues(profile.evidenceOrder, selectedProfile.evidenceOrder)
117
+ || !sameValues(profile.includedEvidence, selectedProfile.includedEvidence)
118
+ || !sameValues(profile.excludedEvidence, selectedProfile.excludedEvidence)
119
+ || !sameValues(profile.redactions, legacyProfileRedactions(profileId))
120
+ || !sameValues(profile.attachments, selectedProfile.attachments)
121
+ || !sameValues(profile.followUpFields, selectedProfile.followUpFields))
122
+ fail('Case-response profile');
123
+ const canonicalUrls = urls.map((item) => {
124
+ try {
125
+ const parsed = new URL(item);
126
+ return ['http:', 'https:'].includes(parsed.protocol) && !parsed.username && !parsed.password
127
+ ? parsed.toString()
128
+ : null;
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ });
134
+ if (canonicalUrls.some((item, index) => item === null || item !== urls[index])
135
+ || new Set(urls).size !== urls.length)
136
+ fail('Case-response abusive URLs');
137
+ const contacts = array(root.contacts, 'Case-response contacts', MAX_RESPONSE_CONTACTS);
138
+ const contactKeys = new Set();
139
+ for (const candidate of contacts) {
140
+ const contact = exact(candidate, ['kind', 'contact', 'source', 'limitations'], 'Case-response contact');
141
+ const kind = enumeration(contact.kind, PRE_PLATFORM_CONTACT_KINDS, 'Case-response contact kind');
142
+ const contactValue = text(contact.contact, 'Case-response contact value', MAX_RESPONSE_RECIPIENT_LENGTH);
143
+ const contactKey = `${kind}\u0000${contactValue.toLowerCase()}`;
144
+ if (contactKeys.has(contactKey))
145
+ fail('Case-response contacts');
146
+ contactKeys.add(contactKey);
147
+ text(contact.source, 'Case-response contact source', MAX_RESPONSE_LABEL_LENGTH);
148
+ strings(contact.limitations, 'Case-response contact limitations', MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH);
149
+ }
150
+ const preflight = exact(root.preflight, ['version', 'status', 'canExport', 'counts', 'checks', 'actionSummary'], 'Case-response preflight');
151
+ if (preflight.version !== 1)
152
+ fail('Case-response preflight');
153
+ const checks = array(preflight.checks, 'Case-response preflight checks', CASE_RESPONSE_PREFLIGHT_IDS_V6.length, CASE_RESPONSE_PREFLIGHT_IDS_V6.length);
154
+ const actualCounts = { block: 0, caution: 0, pass: 0 };
155
+ for (const [index, candidate] of checks.entries()) {
156
+ const check = exact(candidate, ['id', 'label', 'state', 'detail'], 'Case-response preflight check');
157
+ const id = text(check.id, 'Case-response preflight id', 80);
158
+ if (id !== CASE_RESPONSE_PREFLIGHT_IDS_V6[index])
159
+ fail('Case-response preflight check order');
160
+ text(check.label, 'Case-response preflight label', 200);
161
+ const state = enumeration(check.state, ['block', 'caution', 'pass'], 'Case-response preflight state');
162
+ actualCounts[state] += 1;
163
+ text(check.detail, 'Case-response preflight detail', 1_000);
164
+ }
165
+ const counts = exact(preflight.counts, ['block', 'caution', 'pass'], 'Case-response preflight counts');
166
+ for (const state of ['block', 'caution', 'pass']) {
167
+ if (integer(counts[state], 'Case-response preflight count', 0, checks.length) !== actualCounts[state])
168
+ fail('Case-response preflight counts');
169
+ }
170
+ const expectedStatus = actualCounts.block ? 'needs_input' : actualCounts.caution ? 'review_cautions' : 'ready_for_review';
171
+ if (preflight.status !== expectedStatus || preflight.canExport !== (actualCounts.block === 0))
172
+ fail('Case-response preflight status');
173
+ validatePublicActionSummary(preflight.actionSummary, 'Case-response action summary');
174
+ const history = array(root.escalationHistory, 'Case-response escalation history', MAX_RESPONSE_ACTION_HISTORY);
175
+ for (const candidate of history) {
176
+ const action = exact(candidate, ['type', 'recipient', 'contactSource', 'state', 'reference', 'outcome', 'createdAt', 'updatedAt'], 'Case-response escalation action');
177
+ enumeration(action.type, PRE_PLATFORM_ACTION_TYPES, 'Case-response action type');
178
+ text(action.recipient, 'Case-response action recipient', 320, true);
179
+ text(action.contactSource, 'Case-response action source', 120, true);
180
+ enumeration(action.state, PUBLIC_CASE_ACTION_STATES, 'Case-response action state');
181
+ optionalText(action.reference, 'Case-response action reference', 500);
182
+ optionalText(action.outcome, 'Case-response action outcome', 2_000);
183
+ iso(action.createdAt, 'Case-response action createdAt');
184
+ iso(action.updatedAt, 'Case-response action updatedAt');
185
+ }
186
+ const provenance = exact(root.provenance, ['latestEvidenceCapturedAt', 'evidencePinCount', 'decisionCount', 'assertionCount', 'observationAge', 'limitations'], 'Case-response provenance');
187
+ iso(provenance.latestEvidenceCapturedAt, 'Case-response evidence time', true);
188
+ integer(provenance.evidencePinCount, 'Case-response evidence pin count', 0, MAX_CASE_EVIDENCE_PINS);
189
+ integer(provenance.decisionCount, 'Case-response decision count', 0, MAX_CASE_DECISIONS);
190
+ integer(provenance.assertionCount, 'Case-response assertion count', 0, MAX_CASE_ASSERTIONS);
191
+ const age = exact(provenance.observationAge, ['ageSeconds', 'band', 'refreshRecommended'], 'Case-response observation age');
192
+ integer(age.ageSeconds, 'Case-response observation age seconds', Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
193
+ enumeration(age.band, ['future_or_clock_skew', 'one_to_seven_days', 'over_seven_days', 'under_24_hours'], 'Case-response observation age band');
194
+ boolean(age.refreshRecommended, 'Case-response refresh recommendation');
195
+ const expectedAge = expectedObservationAge(incident.observedAt, root.generatedAt);
196
+ if (age.ageSeconds !== expectedAge.ageSeconds || age.band !== expectedAge.band
197
+ || age.refreshRecommended !== expectedAge.refreshRecommended)
198
+ fail('Case-response observation age');
199
+ strings(provenance.limitations, 'Case-response limitations', 8, 600);
200
+ const integrity = exact(root.integrity, ['algorithm', 'canonicalization', 'scope', 'digestSha256'], 'Case-response integrity');
201
+ if (integrity.algorithm !== 'SHA-256'
202
+ || integrity.canonicalization !== 'sorted-json-v2'
203
+ || integrity.scope !== 'packet excluding integrity')
204
+ fail('Case-response integrity');
205
+ if (typeof integrity.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(integrity.digestSha256))
206
+ fail('Case-response integrity');
207
+ }
208
+ function validateCurrentActionSummary(value, label) {
209
+ const summary = exact(value, [
210
+ 'total', 'active', 'drafting', 'readyForReview', 'reviewed', 'authorised', 'submitted',
211
+ 'acknowledged', 'terminal', 'overdue', 'followUpDue', 'withProviderOutcome', 'latestOutcomes',
212
+ ], label);
213
+ const total = integer(summary.total, label, 0, MAX_CASE_ACTIONS);
214
+ const drafting = integer(summary.drafting, label, 0, total);
215
+ const ready = integer(summary.readyForReview, label, 0, total);
216
+ const reviewed = integer(summary.reviewed, label, 0, total);
217
+ const authorised = integer(summary.authorised, label, 0, total);
218
+ const submitted = integer(summary.submitted, label, 0, total);
219
+ const acknowledged = integer(summary.acknowledged, label, 0, total);
220
+ const terminal = integer(summary.terminal, label, 0, total);
221
+ const active = integer(summary.active, label, 0, total);
222
+ if (drafting + ready + reviewed + authorised + submitted + acknowledged + terminal !== total || active + terminal !== total)
223
+ fail(label);
224
+ for (const key of ['overdue', 'followUpDue', 'withProviderOutcome'])
225
+ integer(summary[key], label, 0, total);
226
+ const outcomes = array(summary.latestOutcomes, label, 5);
227
+ for (const candidate of outcomes) {
228
+ const outcome = exact(candidate, ['actionId', 'recipient', 'state', 'providerOutcome', 'outcomeDetail', 'occurredAt'], label);
229
+ text(outcome.actionId, label, 64);
230
+ text(outcome.recipient, label, 320);
231
+ enumeration(outcome.state, CASE_ACTION_STATES, label);
232
+ enumeration(outcome.providerOutcome, CASE_PROVIDER_OUTCOMES, label);
233
+ optionalText(outcome.outcomeDetail, label, 2_000);
234
+ iso(outcome.occurredAt, label);
235
+ }
236
+ return total;
237
+ }
238
+ function validateVersionedCaseResponsePacket(value, version) {
239
+ const hasPlatformRoutes = version >= PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION;
240
+ const hasActionBinding = version >= PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION;
241
+ const root = exact(value, [
242
+ 'schema', 'schemaVersion', 'generatedAt', 'reviewRequired', 'submissionPerformed', 'profile', 'case',
243
+ 'incident', 'contacts', 'selectedEvidence', 'contradictions', 'readiness', 'artefactReferences',
244
+ 'authorisation', 'preflight', 'escalationHistory', 'escalationHistoryOmitted',
245
+ 'escalationHistoryLimitations', 'responseLifecycle', 'provenance', 'integrity',
246
+ ...(hasActionBinding ? ['recipientRoute', 'actionBinding'] : []),
247
+ ], `Case-response packet v${version}`);
248
+ if (root.schemaVersion !== version)
249
+ fail(`Case-response packet v${version}`);
250
+ iso(root.generatedAt, 'Case-response packet generatedAt');
251
+ if (root.reviewRequired !== true || root.submissionPerformed !== false)
252
+ fail('Case-response packet review state');
253
+ const profile = exact(root.profile, ['id', 'label', 'audience', 'subject', 'checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'], 'Case-response profile');
254
+ const profileId = enumeration(profile.id, hasPlatformRoutes ? RESPONSE_PACKET_PROFILE_IDS : PRE_PLATFORM_PROFILE_IDS, 'Case-response profile id');
255
+ text(profile.label, 'Case-response profile label', 200);
256
+ text(profile.audience, 'Case-response profile audience', 300);
257
+ text(profile.subject, 'Case-response profile subject', 500);
258
+ for (const key of ['checklist', 'evidenceOrder', 'includedEvidence', 'excludedEvidence', 'redactions', 'attachments', 'followUpFields'])
259
+ strings(profile[key], `Case-response profile ${key}`, 24, 500);
260
+ const selectedProfile = RESPONSE_PACKET_PROFILES.find((candidate) => candidate.id === profileId);
261
+ const expectedRedactions = hasActionBinding ? selectedProfile?.redactions : legacyProfileRedactions(profileId);
262
+ if (!selectedProfile
263
+ || profile.label !== selectedProfile.label
264
+ || profile.audience !== selectedProfile.audience
265
+ || !sameValues(profile.checklist, selectedProfile.checklist)
266
+ || !sameValues(profile.evidenceOrder, selectedProfile.evidenceOrder)
267
+ || !sameValues(profile.includedEvidence, selectedProfile.includedEvidence)
268
+ || !sameValues(profile.excludedEvidence, selectedProfile.excludedEvidence)
269
+ || !sameValues(profile.redactions, expectedRedactions ?? [])
270
+ || !sameValues(profile.attachments, selectedProfile.attachments)
271
+ || !sameValues(profile.followUpFields, selectedProfile.followUpFields))
272
+ fail('Case-response profile');
273
+ const caseRecord = exact(root.case, ['id', 'domain', 'status', 'disposition', 'updatedAt'], 'Case-response case');
274
+ text(caseRecord.id, 'Case-response case id', 128);
275
+ domain(caseRecord.domain, 'Case-response case domain');
276
+ text(caseRecord.status, 'Case-response case status', 80);
277
+ text(caseRecord.disposition, 'Case-response case disposition', 80);
278
+ iso(caseRecord.updatedAt, 'Case-response case updatedAt');
279
+ const incident = exact(root.incident, ['category', 'affectedParty', 'abusiveUrls', 'observedHarm', 'observedAt'], 'Case-response incident');
280
+ text(incident.category, 'Case-response category', 80);
281
+ text(incident.affectedParty, 'Case-response affected party', 200);
282
+ const urls = strings(incident.abusiveUrls, 'Case-response abusive URLs', MAX_ABUSIVE_URLS, 2_048);
283
+ if (!urls.length || urls.some((item) => {
284
+ try {
285
+ const parsed = new URL(item);
286
+ return !['http:', 'https:'].includes(parsed.protocol) || Boolean(parsed.username || parsed.password) || parsed.toString() !== item;
287
+ }
288
+ catch {
289
+ return true;
290
+ }
291
+ }) || new Set(urls).size !== urls.length)
292
+ fail('Case-response abusive URLs');
293
+ text(incident.observedHarm, 'Case-response observed harm', 2_000);
294
+ iso(incident.observedAt, 'Case-response observedAt');
295
+ if (profile.subject !== `${selectedProfile?.subjectPrefix}: ${caseRecord.domain} (${incident.category})`
296
+ && (version < 13 || profile.subject !== `Correction or retraction request: ${caseRecord.domain} (${incident.category})`))
297
+ fail('Case-response profile subject');
298
+ const contacts = array(root.contacts, 'Case-response contacts', MAX_RESPONSE_CONTACTS);
299
+ const contactKeys = new Set();
300
+ for (const candidate of contacts) {
301
+ const contact = exact(candidate, [
302
+ 'kind', 'contact', 'source', 'observedAt', ...(hasPlatformRoutes ? ['reviewAfter'] : []), 'freshness', 'limitations',
303
+ ], 'Case-response contact');
304
+ const kind = enumeration(contact.kind, hasPlatformRoutes ? RESPONSE_CONTACT_KINDS : PRE_PLATFORM_CONTACT_KINDS, 'Case-response contact kind');
305
+ const contactValue = text(contact.contact, 'Case-response contact value', MAX_RESPONSE_RECIPIENT_LENGTH);
306
+ const key = `${kind}\u0000${contactValue.toLowerCase()}`;
307
+ if (contactKeys.has(key))
308
+ fail('Case-response contacts');
309
+ contactKeys.add(key);
310
+ text(contact.source, 'Case-response contact source', 120);
311
+ iso(contact.observedAt, 'Case-response contact observedAt', true);
312
+ if (hasPlatformRoutes)
313
+ iso(contact.reviewAfter, 'Case-response contact reviewAfter', true);
314
+ const freshness = enumeration(contact.freshness, ['current', 'stale', 'unknown'], 'Case-response contact freshness');
315
+ const routeAge = contact.observedAt === null
316
+ ? null
317
+ : Date.parse(root.generatedAt) - Date.parse(contact.observedAt);
318
+ const reviewExpired = hasPlatformRoutes && contact.reviewAfter !== null
319
+ && Date.parse(root.generatedAt) >= Date.parse(contact.reviewAfter);
320
+ const expectedFreshness = routeAge === null
321
+ ? 'unknown'
322
+ : routeAge < -300_000 || reviewExpired
323
+ || (contact.reviewAfter == null && routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000)
324
+ ? 'stale'
325
+ : 'current';
326
+ if (freshness !== expectedFreshness)
327
+ fail('Case-response contact freshness');
328
+ strings(contact.limitations, 'Case-response contact limitations', MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH);
329
+ }
330
+ let recipientRoute = null;
331
+ let selectedActionId = null;
332
+ let lineageActionIds = [];
333
+ let lineageComplete = true;
334
+ if (hasActionBinding) {
335
+ if (root.recipientRoute !== null) {
336
+ recipientRoute = exact(root.recipientRoute, [
337
+ 'actionId', 'kind', 'contact', 'source', 'observedAt', ...(hasPlatformRoutes ? ['reviewAfter'] : []), 'freshness', 'limitations',
338
+ ], 'Case-response recipient route');
339
+ text(recipientRoute.actionId, 'Case-response recipient action id', 64);
340
+ enumeration(recipientRoute.kind, [...(hasPlatformRoutes ? RESPONSE_CONTACT_KINDS : PRE_PLATFORM_CONTACT_KINDS), 'manual'], 'Case-response recipient kind');
341
+ text(recipientRoute.contact, 'Case-response recipient value', MAX_RESPONSE_RECIPIENT_LENGTH);
342
+ text(recipientRoute.source, 'Case-response recipient source', 120);
343
+ iso(recipientRoute.observedAt, 'Case-response recipient observedAt', true);
344
+ if (hasPlatformRoutes)
345
+ iso(recipientRoute.reviewAfter, 'Case-response recipient reviewAfter', true);
346
+ const freshness = enumeration(recipientRoute.freshness, ['current', 'stale', 'unknown'], 'Case-response recipient freshness');
347
+ const routeAge = recipientRoute.observedAt === null
348
+ ? null
349
+ : Date.parse(root.generatedAt) - Date.parse(recipientRoute.observedAt);
350
+ const reviewExpired = hasPlatformRoutes && recipientRoute.reviewAfter !== null
351
+ && Date.parse(root.generatedAt) >= Date.parse(recipientRoute.reviewAfter);
352
+ const expectedFreshness = routeAge === null
353
+ ? 'unknown'
354
+ : routeAge < -MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS
355
+ || reviewExpired
356
+ || (recipientRoute.reviewAfter == null && routeAge > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000) ? 'stale' : 'current';
357
+ if (freshness !== expectedFreshness)
358
+ fail('Case-response recipient freshness');
359
+ strings(recipientRoute.limitations, 'Case-response recipient limitations', MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH);
360
+ }
361
+ const binding = exact(root.actionBinding, [
362
+ 'state', 'selectedActionId', 'lineageActionIds', 'limitations',
363
+ ], 'Case-response action binding');
364
+ const state = enumeration(binding.state, ['selected', 'not_selected'], 'Case-response action binding state');
365
+ optionalText(binding.selectedActionId, 'Case-response selected action id', 64);
366
+ selectedActionId = binding.selectedActionId;
367
+ lineageActionIds = strings(binding.lineageActionIds, 'Case-response action lineage', MAX_RESPONSE_ACTION_HISTORY, 64);
368
+ const bindingLimitations = strings(binding.limitations, 'Case-response action-binding limitations', MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH);
369
+ lineageComplete = !bindingLimitations.some((item) => /lineage is incomplete/iu.test(item));
370
+ if ((state === 'selected') !== Boolean(selectedActionId)
371
+ || (selectedActionId && lineageActionIds[0] !== selectedActionId)
372
+ || new Set(lineageActionIds).size !== lineageActionIds.length
373
+ || (recipientRoute && recipientRoute.actionId !== selectedActionId))
374
+ fail('Case-response action binding');
375
+ if (recipientRoute?.kind === 'manual' && contacts.length !== 0)
376
+ fail('Case-response manual recipient route');
377
+ if (recipientRoute && recipientRoute.kind !== 'manual') {
378
+ if (contacts.length !== 1)
379
+ fail('Case-response selected contact projection');
380
+ const selectedContact = contacts[0];
381
+ if (selectedContact.kind !== recipientRoute.kind
382
+ || selectedContact.contact !== recipientRoute.contact
383
+ || selectedContact.source !== recipientRoute.source
384
+ || selectedContact.observedAt !== recipientRoute.observedAt
385
+ || (hasPlatformRoutes && selectedContact.reviewAfter !== recipientRoute.reviewAfter)
386
+ || selectedContact.freshness !== recipientRoute.freshness)
387
+ fail('Case-response selected contact projection');
388
+ }
389
+ }
390
+ const evidence = array(root.selectedEvidence, 'Case-response selected evidence', MAX_RESPONSE_SELECTED_EVIDENCE);
391
+ const evidenceIds = new Set();
392
+ for (const candidate of evidence) {
393
+ const item = exactOptional(candidate, ['id', 'label', 'source', 'observedAt', 'completeness', 'limitations'], [...(version > PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION ? ['observationHostname', 'webObservationMode'] : []), ...(version >= 12 ? ['responseObject'] : [])], 'Case-response selected evidence');
394
+ readCaseResponseObject(item.responseObject);
395
+ if (item.webObservationMode !== undefined && item.webObservationMode !== 'selected_url')
396
+ fail('Case-response web observation mode');
397
+ const id = text(item.id, 'Case-response evidence id', 64);
398
+ if (evidenceIds.has(id))
399
+ fail('Case-response selected evidence');
400
+ evidenceIds.add(id);
401
+ text(item.label, 'Case-response evidence label', 80);
402
+ text(item.source, 'Case-response evidence source', 120);
403
+ if (item.observationHostname !== undefined)
404
+ domain(item.observationHostname, 'Case-response evidence hostname');
405
+ iso(item.observedAt, 'Case-response evidence observedAt', version > PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION);
406
+ enumeration(item.completeness, ['complete', 'partial', 'inconclusive', 'unknown'], 'Case-response evidence completeness');
407
+ strings(item.limitations, 'Case-response evidence limitations', 8, 240);
408
+ }
409
+ const contradictions = array(root.contradictions, 'Case-response contradictions', MAX_RESPONSE_CONTRADICTIONS);
410
+ const contradictionIds = new Set();
411
+ for (const candidate of contradictions) {
412
+ const item = exact(candidate, ['id', 'statement', 'state', 'limitations'], 'Case-response contradiction');
413
+ const id = text(item.id, 'Case-response contradiction id', 64);
414
+ if (contradictionIds.has(id))
415
+ fail('Case-response contradictions');
416
+ contradictionIds.add(id);
417
+ text(item.statement, 'Case-response contradiction statement', 2_000);
418
+ enumeration(item.state, ['open', 'resolved'], 'Case-response contradiction state');
419
+ strings(item.limitations, 'Case-response contradiction limitations', 8, 240);
420
+ }
421
+ const readiness = exact(root.readiness, ['profileId', 'rows', 'counts', 'limitations'], 'Case-response readiness');
422
+ if (readiness.profileId !== profileId)
423
+ fail('Case-response readiness profile');
424
+ const readinessRows = array(readiness.rows, 'Case-response readiness rows', RESPONSE_READINESS_ROW_IDS.length, RESPONSE_READINESS_ROW_IDS.length);
425
+ const actualReadiness = Object.fromEntries(RESPONSE_READINESS_STATES.map((state) => [state, 0]));
426
+ const requiredReadiness = new Set([
427
+ 'observed_behaviour', 'exact_url', 'observation_time', 'capture_provenance', 'authority_review',
428
+ 'selected_evidence', 'contradictions', 'source_limitations',
429
+ ...(profileId === 'internal_soc' ? [] : ['recipient_route']),
430
+ ...(['registrar', 'registry', 'network_hosting'].includes(profileId) ? ['infrastructure_responsibility'] : []),
431
+ ]);
432
+ const requiredContact = hasActionBinding
433
+ ? recipientRoute ?? undefined
434
+ : selectedProfile?.requiredContactKind
435
+ ? contacts.find((candidate) => candidate.kind === selectedProfile.requiredContactKind)
436
+ : contacts[0];
437
+ const expectedReadinessStates = {
438
+ observed_behaviour: 'complete',
439
+ exact_url: 'complete',
440
+ observation_time: expectedObservationAge(incident.observedAt, root.generatedAt).refreshRecommended ? 'stale' : 'complete',
441
+ capture_provenance: !evidence.length ? 'unavailable' : evidence.every((candidate) => candidate.observedAt !== null) ? 'complete' : 'partial',
442
+ recipient_route: !requiredContact
443
+ ? 'not_provided'
444
+ : requiredContact.freshness === 'stale'
445
+ ? 'stale'
446
+ : requiredContact.freshness === 'current' && requiredContact.source ? 'complete' : 'partial',
447
+ selected_evidence: !evidence.length
448
+ ? 'not_provided'
449
+ : evidence.every((candidate) => candidate.completeness === 'complete') ? 'complete' : 'partial',
450
+ ...(contradictions.length ? {
451
+ contradictions: contradictions.some((candidate) => candidate.state === 'open') ? 'partial' : 'complete',
452
+ } : {}),
453
+ };
454
+ for (const [index, candidate] of readinessRows.entries()) {
455
+ const row = exact(candidate, ['id', 'label', 'state', 'detail', 'requiredForAuthorisation', 'limitations'], 'Case-response readiness row');
456
+ if (row.id !== RESPONSE_READINESS_ROW_IDS[index])
457
+ fail('Case-response readiness row order');
458
+ text(row.label, 'Case-response readiness label', 200);
459
+ const state = enumeration(row.state, RESPONSE_READINESS_STATES, 'Case-response readiness state');
460
+ actualReadiness[state] = (actualReadiness[state] ?? 0) + 1;
461
+ text(row.detail, 'Case-response readiness detail', 500);
462
+ boolean(row.requiredForAuthorisation, 'Case-response readiness requirement');
463
+ if (row.requiredForAuthorisation !== requiredReadiness.has(row.id)
464
+ || (expectedReadinessStates[row.id] !== undefined
465
+ && row.state !== expectedReadinessStates[row.id]))
466
+ fail('Case-response readiness projection');
467
+ strings(row.limitations, 'Case-response readiness limitations', 8, 240);
468
+ }
469
+ const readinessCounts = exact(readiness.counts, [...RESPONSE_READINESS_STATES], 'Case-response readiness counts');
470
+ for (const state of RESPONSE_READINESS_STATES)
471
+ if (integer(readinessCounts[state], 'Case-response readiness count', 0, readinessRows.length) !== actualReadiness[state])
472
+ fail('Case-response readiness counts');
473
+ strings(readiness.limitations, 'Case-response readiness limitations', 8, 600);
474
+ const artefacts = array(root.artefactReferences, 'Case-response artefact references', MAX_RESPONSE_ARTEFACT_REFERENCES);
475
+ const artefactIds = new Set();
476
+ for (const candidate of artefacts) {
477
+ const item = exact(candidate, ['id', 'label', 'mediaType', 'capturedAt', 'source', 'digestSha256', 'byteLength', 'limitations'], 'Case-response artefact reference');
478
+ const id = text(item.id, 'Case-response artefact id', 64);
479
+ if (artefactIds.has(id))
480
+ fail('Case-response artefact references');
481
+ artefactIds.add(id);
482
+ text(item.label, 'Case-response artefact label', 120);
483
+ text(item.mediaType, 'Case-response artefact media type', 120);
484
+ iso(item.capturedAt, 'Case-response artefact capturedAt');
485
+ text(item.source, 'Case-response artefact source', 120);
486
+ if (typeof item.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(item.digestSha256))
487
+ fail('Case-response artefact digest');
488
+ if (item.byteLength !== null)
489
+ integer(item.byteLength, 'Case-response artefact byte length', 0, 100 * 1024 * 1024);
490
+ strings(item.limitations, 'Case-response artefact limitations', 8, 240);
491
+ }
492
+ const authorisation = exact(root.authorisation, ['status', 'reviewedInputDigestSha256', 'suppliedReviewDigestSha256', 'digestMatches', 'confirmedAt', 'confirmations', 'missingConfirmations', 'limitations'], 'Case-response authorisation');
493
+ const authorisationStatus = enumeration(authorisation.status, ['draft', 'authorised'], 'Case-response authorisation status');
494
+ if (typeof authorisation.reviewedInputDigestSha256 !== 'string' || !HEX_DIGEST_RE.test(authorisation.reviewedInputDigestSha256))
495
+ fail('Case-response review digest');
496
+ if (authorisation.suppliedReviewDigestSha256 !== null && (typeof authorisation.suppliedReviewDigestSha256 !== 'string' || !HEX_DIGEST_RE.test(authorisation.suppliedReviewDigestSha256)))
497
+ fail('Case-response supplied review digest');
498
+ boolean(authorisation.digestMatches, 'Case-response authorisation digest match');
499
+ iso(authorisation.confirmedAt, 'Case-response authorisation confirmedAt', true);
500
+ if (authorisation.confirmedAt !== null
501
+ && Date.parse(authorisation.confirmedAt) > Date.parse(root.generatedAt) + MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS) {
502
+ fail('Case-response authorisation confirmedAt');
503
+ }
504
+ const confirmations = exact(authorisation.confirmations, [...RESPONSE_AUTHORISATION_CONFIRMATION_IDS], 'Case-response confirmations');
505
+ for (const id of RESPONSE_AUTHORISATION_CONFIRMATION_IDS)
506
+ boolean(confirmations[id], `Case-response confirmation ${id}`);
507
+ const missing = strings(authorisation.missingConfirmations, 'Case-response missing confirmations', RESPONSE_AUTHORISATION_CONFIRMATION_IDS.length, 80);
508
+ if (missing.some((id) => !RESPONSE_AUTHORISATION_CONFIRMATION_IDS.includes(id)))
509
+ fail('Case-response missing confirmations');
510
+ strings(authorisation.limitations, 'Case-response authorisation limitations', 8, 600);
511
+ const expectedMissing = RESPONSE_AUTHORISATION_CONFIRMATION_IDS.filter((id) => confirmations[id] !== true);
512
+ const expectedDigestMatches = authorisation.suppliedReviewDigestSha256 === authorisation.reviewedInputDigestSha256;
513
+ const missingRequiredReadiness = readinessRows.some((candidate) => {
514
+ const row = candidate;
515
+ return row.requiredForAuthorisation === true && (row.state === 'not_provided' || row.state === 'unavailable');
516
+ });
517
+ const authorityReady = readinessRows.some((candidate) => {
518
+ const row = candidate;
519
+ return row.id === 'authority_review' && row.state === 'complete';
520
+ });
521
+ if (authorisation.digestMatches !== expectedDigestMatches
522
+ || !sameValues(missing, expectedMissing)
523
+ || (authorisationStatus === 'authorised' && (authorisation.digestMatches !== true || authorisation.confirmedAt === null || missing.length || missingRequiredReadiness || !authorityReady))
524
+ || (authorisationStatus === 'draft' && authorisation.confirmedAt !== null))
525
+ fail('Case-response authorisation');
526
+ const preflight = exact(root.preflight, ['version', 'status', 'canExport', 'counts', 'checks', 'actionSummary'], 'Case-response preflight');
527
+ const preflightIds = hasActionBinding ? CASE_RESPONSE_PREFLIGHT_IDS_V8 : CASE_RESPONSE_PREFLIGHT_IDS_V7;
528
+ if (preflight.version !== (hasActionBinding ? 3 : 2))
529
+ fail('Case-response preflight');
530
+ const checks = array(preflight.checks, 'Case-response preflight checks', preflightIds.length, preflightIds.length);
531
+ const actualCounts = { block: 0, caution: 0, pass: 0 };
532
+ for (const [index, candidate] of checks.entries()) {
533
+ const check = exact(candidate, ['id', 'label', 'state', 'detail'], 'Case-response preflight check');
534
+ if (check.id !== preflightIds[index])
535
+ fail('Case-response preflight check order');
536
+ text(check.label, 'Case-response preflight label', 200);
537
+ const state = enumeration(check.state, ['block', 'caution', 'pass'], 'Case-response preflight state');
538
+ actualCounts[state] += 1;
539
+ text(check.detail, 'Case-response preflight detail', 1_000);
540
+ }
541
+ const counts = exact(preflight.counts, ['block', 'caution', 'pass'], 'Case-response preflight counts');
542
+ for (const state of ['block', 'caution', 'pass'])
543
+ if (integer(counts[state], 'Case-response preflight count', 0, checks.length) !== actualCounts[state])
544
+ fail('Case-response preflight counts');
545
+ const expectedStatus = actualCounts.block ? 'needs_input' : actualCounts.caution ? 'review_cautions' : 'ready_for_review';
546
+ if (preflight.status !== expectedStatus || preflight.canExport !== (actualCounts.block === 0))
547
+ fail('Case-response preflight status');
548
+ const actionTotal = validateCurrentActionSummary(preflight.actionSummary, 'Case-response action summary');
549
+ const history = array(root.escalationHistory, 'Case-response escalation history', MAX_RESPONSE_ACTION_HISTORY);
550
+ const escalationHistoryOmitted = integer(root.escalationHistoryOmitted, 'Case-response omitted actions', 0, MAX_CASE_ACTIONS);
551
+ const escalationHistoryLimitations = strings(root.escalationHistoryLimitations, 'Case-response history limitations', 4, 600);
552
+ if ((!hasActionBinding && history.length !== Math.min(actionTotal, MAX_RESPONSE_ACTION_HISTORY))
553
+ || (hasActionBinding && history.length > Math.min(actionTotal, MAX_RESPONSE_ACTION_HISTORY))
554
+ || escalationHistoryOmitted !== actionTotal - history.length
555
+ || (escalationHistoryOmitted > 0) !== (escalationHistoryLimitations.length > 0))
556
+ fail('Case-response action-history bounds');
557
+ const actionIds = new Set();
558
+ const actionIdOrder = [];
559
+ const providerEvents = [];
560
+ for (const candidate of history) {
561
+ const action = exactOptional(candidate, [
562
+ 'actionId', 'type', 'recipient', 'contactSource', 'state', 'reference',
563
+ 'providerOutcome', 'outcomeDetail', 'originActionId', 'historyOmitted',
564
+ 'historyLimitations', 'transitions', 'createdAt', 'updatedAt',
565
+ ...(hasActionBinding ? ['routeObservedAt'] : []),
566
+ ...(hasPlatformRoutes ? ['routeReviewAfter'] : []),
567
+ ], [...(Number(root.schemaVersion) >= 11 ? ['amendment'] : []), ...(version >= 12 ? ['responseObjects'] : []), ...(version >= 13 ? ['correction'] : [])], 'Case-response escalation action');
568
+ readCaseResponseObjects(action.responseObjects);
569
+ readCasePacketAmendment(action.amendment);
570
+ if (readCasePacketCorrection(action.correction) && action.amendment)
571
+ throw new TypeError('Correction and provider amendment must remain separate.');
572
+ const actionId = text(action.actionId, 'Case-response action id', 64);
573
+ if (actionIds.has(actionId))
574
+ fail('Case-response action identity');
575
+ actionIds.add(actionId);
576
+ actionIdOrder.push(actionId);
577
+ enumeration(action.type, hasPlatformRoutes ? CASE_ACTION_TYPES : PRE_PLATFORM_ACTION_TYPES, 'Case-response action type');
578
+ text(action.recipient, 'Case-response action recipient', 320);
579
+ text(action.contactSource, 'Case-response action source', 120);
580
+ if (hasActionBinding)
581
+ iso(action.routeObservedAt, 'Case-response action route observedAt', true);
582
+ if (hasPlatformRoutes)
583
+ iso(action.routeReviewAfter, 'Case-response action route reviewAfter', true);
584
+ enumeration(action.state, CASE_ACTION_STATES, 'Case-response action state');
585
+ optionalText(action.reference, 'Case-response action reference', 500);
586
+ if (action.providerOutcome !== null)
587
+ enumeration(action.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response provider outcome');
588
+ optionalText(action.outcomeDetail, 'Case-response action outcome detail', 2_000);
589
+ optionalText(action.originActionId, 'Case-response action origin', 64);
590
+ integer(action.historyOmitted, 'Case-response action omitted history', 0, 1_000_000);
591
+ strings(action.historyLimitations, 'Case-response action history limitations', 8, 240);
592
+ const transitions = array(action.transitions, 'Case-response action transitions', MAX_CASE_ACTION_EVENTS_PER_ACTION, 1);
593
+ const requestEvents = [];
594
+ const eventIds = new Set();
595
+ let projectedState = null;
596
+ let latestReference = null;
597
+ let latestProviderOutcome = null;
598
+ let latestProviderOutcomeDetail = null;
599
+ let latestOutcomeDetail = null;
600
+ let previousTime = Number.NEGATIVE_INFINITY;
601
+ let previousEventId = '';
602
+ for (const eventCandidate of transitions) {
603
+ const event = exactOptional(eventCandidate, ['id', 'previousState', 'nextState', 'occurredAt', 'sourceClass', 'provenance', 'reference', 'evidencePinId', 'limitations', 'providerOutcome', 'outcomeDetail', 'originActionId', 'applied'], [...(Number(root.schemaVersion) >= 11 ? ['evidenceRequest'] : []), ...(version >= 12 ? ['responseObjects', 'objectOutcome'] : []), ...(version >= 13 ? ['packetReceipt'] : [])], 'Case-response action transition');
604
+ const receipt = readCaseDeliveryPacketReceipt(event.packetReceipt);
605
+ assertDeliveryPacketReceipt(receipt, event, actionId);
606
+ if (receipt && (receipt.caseId !== caseRecord.id || receipt.target !== caseRecord.domain || receipt.recipient !== action.recipient))
607
+ fail('Case-response receipt Case, target or recipient');
608
+ const responseObjects = readCaseResponseObjects(event.responseObjects);
609
+ const objectOutcome = readCaseResponseObjectOutcome(event.objectOutcome);
610
+ if (objectOutcome) {
611
+ if (!responseObjects?.length || !['submitted', 'acknowledged', 'terminal'].includes(String(event.nextState))
612
+ || !['analyst', 'provider'].includes(String(event.sourceClass)) || event.previousState === 'authorised')
613
+ fail('Case-response object outcome scope');
614
+ responseObjects.forEach(object => assertCaseObjectOutcome(objectOutcome, object));
615
+ }
616
+ const evidenceRequest = readCaseEvidenceRequest(event.evidenceRequest);
617
+ assertEvidenceRequestEvent(evidenceRequest, event);
618
+ const eventId = text(event.id, 'Case-response action event id', 64);
619
+ requestEvents.push({ id: eventId, ...(evidenceRequest ? { evidenceRequest } : {}) });
620
+ if (eventIds.has(eventId))
621
+ fail('Case-response action event identity');
622
+ eventIds.add(eventId);
623
+ const previousState = event.previousState === null ? null : enumeration(event.previousState, CASE_ACTION_STATES, 'Case-response previous state');
624
+ const nextState = enumeration(event.nextState, CASE_ACTION_STATES, 'Case-response next state');
625
+ iso(event.occurredAt, 'Case-response action event time');
626
+ const eventTime = Date.parse(event.occurredAt);
627
+ if (eventTime < previousTime || (eventTime === previousTime && eventId < previousEventId))
628
+ fail('Case-response action event order');
629
+ previousTime = eventTime;
630
+ previousEventId = eventId;
631
+ const sourceClass = enumeration(event.sourceClass, CASE_ACTION_EVENT_SOURCE_CLASSES, 'Case-response action event source class');
632
+ text(event.provenance, 'Case-response action event provenance', 80);
633
+ optionalText(event.reference, 'Case-response action event reference', 500);
634
+ optionalText(event.evidencePinId, 'Case-response action event evidence pin', 64);
635
+ strings(event.limitations, 'Case-response action event limitations', 8, 240);
636
+ const providerOutcome = event.providerOutcome === null ? null : enumeration(event.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Case-response action event provider outcome');
637
+ if (providerOutcome !== null && !['submitted', 'acknowledged', 'terminal'].includes(nextState) && sourceClass !== 'migration')
638
+ fail('Case-response action event provider outcome');
639
+ const migrationSnapshot = sourceClass === 'migration' && previousState === null;
640
+ if (providerOutcome !== null && previousState === 'authorised' && nextState === 'submitted' && !migrationSnapshot)
641
+ fail('Case-response submission event outcome');
642
+ if (providerOutcome === 'no_response' && sourceClass === 'provider')
643
+ fail('Case-response no-response event source');
644
+ if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)
645
+ && sourceClass !== 'analyst' && !migrationSnapshot)
646
+ fail('Case-response explicit analyst transition');
647
+ if (nextState === 'terminal' && previousState !== null
648
+ && ['drafting', 'ready_for_review', 'reviewed', 'authorised'].includes(previousState)
649
+ && (sourceClass !== 'analyst' || providerOutcome !== 'withdrawn'))
650
+ fail('Case-response early terminal transition');
651
+ if (providerOutcome === 'withdrawn'
652
+ && (nextState !== 'terminal' || (sourceClass !== 'analyst' && !migrationSnapshot)))
653
+ fail('Case-response withdrawn transition');
654
+ optionalText(event.outcomeDetail, 'Case-response action event outcome detail', 2_000);
655
+ optionalText(event.originActionId, 'Case-response action event origin', 64);
656
+ if (event.originActionId === actionId)
657
+ fail('Case-response action event origin');
658
+ boolean(event.applied, 'Case-response action event projection');
659
+ const applied = event.applied;
660
+ if (providerOutcome !== null) {
661
+ providerEvents.push({
662
+ actionId,
663
+ eventId,
664
+ outcome: providerOutcome,
665
+ occurredAt: event.occurredAt,
666
+ reference: event.reference,
667
+ applied,
668
+ });
669
+ }
670
+ if (projectedState === null && Number(action.historyOmitted) > 0 && previousState !== null)
671
+ projectedState = previousState;
672
+ const expectedApplied = previousState === projectedState
673
+ && isLegalCaseActionTransition(previousState, nextState, sourceClass);
674
+ if (applied !== expectedApplied)
675
+ fail('Case-response action event projection');
676
+ if (applied) {
677
+ projectedState = nextState;
678
+ if (event.reference !== null)
679
+ latestReference = event.reference;
680
+ if (event.outcomeDetail !== null)
681
+ latestOutcomeDetail = event.outcomeDetail;
682
+ if (providerOutcome !== null) {
683
+ latestProviderOutcome = providerOutcome;
684
+ latestProviderOutcomeDetail = event.outcomeDetail;
685
+ }
686
+ }
687
+ }
688
+ assertEvidenceRequestHistory(requestEvents, Number(action.historyOmitted) > 0);
689
+ if ((projectedState ?? 'drafting') !== action.state
690
+ || latestReference !== action.reference
691
+ || latestProviderOutcome !== action.providerOutcome
692
+ || (latestProviderOutcomeDetail ?? latestOutcomeDetail) !== action.outcomeDetail)
693
+ fail('Case-response action projection');
694
+ if (action.originActionId === actionId)
695
+ fail('Case-response action origin');
696
+ iso(action.createdAt, 'Case-response action createdAt');
697
+ iso(action.updatedAt, 'Case-response action updatedAt');
698
+ }
699
+ if (hasActionBinding) {
700
+ if (actionIds.size !== lineageActionIds.length
701
+ || actionIdOrder.some((id, index) => lineageActionIds[index] !== id))
702
+ fail('Case-response action lineage');
703
+ if (recipientRoute && !actionIds.has(recipientRoute.actionId))
704
+ fail('Case-response recipient action');
705
+ assertCaseResponseRecipientAction(recipientRoute, history, hasPlatformRoutes);
706
+ }
707
+ if (version >= 11)
708
+ assertPacketAmendmentSelection(history.map(candidate => {
709
+ const action = candidate;
710
+ return { id: action.actionId, history: action.transitions, originActionId: action.originActionId, amendment: action.amendment };
711
+ }), selectedActionId, evidenceIds);
712
+ if (version >= 13) {
713
+ const actions = history.map(candidate => { const action = candidate; return { ...action, id: action.actionId, history: action.transitions }; });
714
+ const selected = actions.find(action => action.id === selectedActionId);
715
+ if (selected?.correction)
716
+ correctionDelivery(actions, selected, { caseId: caseRecord.id, target: caseRecord.domain, profile: profile.id, selectedPinIds: evidenceIds });
717
+ const expectedPrefix = selected?.correction ? 'Correction or retraction request' : selectedProfile?.subjectPrefix;
718
+ if (profile.subject !== `${expectedPrefix}: ${caseRecord.domain} (${incident.category})`)
719
+ fail('Case-response correction subject');
720
+ }
721
+ const lifecycle = exact(root.responseLifecycle, ['providerOutcomeState', 'latestProviderOutcome', 'observedChangeState', 'latestObservedEffect', 'latestObservedChangeAt', 'closure', 'limitations'], 'Case-response lifecycle');
722
+ const providerOutcomeState = enumeration(lifecycle.providerOutcomeState, ['available', 'missing', 'ambiguous'], 'Case-response lifecycle provider state');
723
+ let actualLatestProvider = null;
724
+ if (lifecycle.latestProviderOutcome !== null) {
725
+ const outcome = exact(lifecycle.latestProviderOutcome, ['actionId', 'eventId', 'outcome', 'occurredAt', 'reference'], 'Case-response lifecycle provider outcome');
726
+ const actionId = text(outcome.actionId, 'Case-response lifecycle action id', 64);
727
+ const eventId = text(outcome.eventId, 'Case-response lifecycle event id', 64);
728
+ const providerOutcome = enumeration(outcome.outcome, CASE_PROVIDER_OUTCOMES, 'Case-response lifecycle provider outcome');
729
+ iso(outcome.occurredAt, 'Case-response lifecycle provider time');
730
+ const occurredAt = outcome.occurredAt;
731
+ optionalText(outcome.reference, 'Case-response lifecycle reference', 500);
732
+ actualLatestProvider = {
733
+ actionId,
734
+ eventId,
735
+ outcome: providerOutcome,
736
+ occurredAt,
737
+ reference: outcome.reference,
738
+ };
739
+ }
740
+ providerEvents.sort((left, right) => Date.parse(right.occurredAt) - Date.parse(left.occurredAt)
741
+ || (right.eventId < left.eventId ? -1 : right.eventId > left.eventId ? 1 : 0));
742
+ const latestProviderTime = providerEvents[0]?.occurredAt ?? null;
743
+ const latestProviderEvents = latestProviderTime === null
744
+ ? []
745
+ : providerEvents.filter((event) => event.occurredAt === latestProviderTime);
746
+ const expectedLatestProvider = (hasActionBinding ? lineageComplete : escalationHistoryOmitted === 0)
747
+ && latestProviderEvents.length === 1 && latestProviderEvents[0].applied
748
+ ? latestProviderEvents[0]
749
+ : null;
750
+ const expectedProviderState = (hasActionBinding ? !lineageComplete : escalationHistoryOmitted > 0)
751
+ ? 'ambiguous'
752
+ : providerEvents.length === 0 ? 'missing' : expectedLatestProvider ? 'available' : 'ambiguous';
753
+ if (providerOutcomeState !== expectedProviderState
754
+ || (actualLatestProvider === null) !== (expectedLatestProvider === null)
755
+ || (actualLatestProvider && expectedLatestProvider && (actualLatestProvider.actionId !== expectedLatestProvider.actionId
756
+ || actualLatestProvider.eventId !== expectedLatestProvider.eventId
757
+ || actualLatestProvider.outcome !== expectedLatestProvider.outcome
758
+ || actualLatestProvider.occurredAt !== expectedLatestProvider.occurredAt
759
+ || actualLatestProvider.reference !== expectedLatestProvider.reference)))
760
+ fail('Case-response lifecycle provider state');
761
+ const observedChangeState = enumeration(lifecycle.observedChangeState, ['available', 'missing', 'ambiguous'], 'Case-response lifecycle observed-change state');
762
+ if (lifecycle.latestObservedEffect !== null) {
763
+ const effect = exactOptional(lifecycle.latestObservedEffect, ['reviewId', 'state', 'observedAt', 'sourceClass', 'source'], version >= 12 ? ['responseObject', 'objectOutcome'] : [], 'Case-response lifecycle observed effect');
764
+ const responseObject = readCaseResponseObject(effect.responseObject);
765
+ const objectOutcome = readCaseResponseObjectOutcome(effect.objectOutcome);
766
+ assertCaseObjectOutcome(objectOutcome, responseObject);
767
+ text(effect.reviewId, 'Case-response effect review id', 64);
768
+ enumeration(effect.state, CASE_OBSERVED_EFFECT_STATES, 'Case-response effect state');
769
+ iso(effect.observedAt, 'Case-response effect observedAt');
770
+ enumeration(effect.sourceClass, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, 'Case-response effect source class');
771
+ text(effect.source, 'Case-response effect source', 80);
772
+ }
773
+ iso(lifecycle.latestObservedChangeAt, 'Case-response observed change time', true);
774
+ if ((observedChangeState === 'available') !== (lifecycle.latestObservedChangeAt !== null))
775
+ fail('Case-response lifecycle observed-change state');
776
+ if (lifecycle.closure !== null) {
777
+ const closure = exact(lifecycle.closure, ['id', 'reason', 'createdAt', 'limitations'], 'Case-response closure');
778
+ text(closure.id, 'Case-response closure id', 64);
779
+ enumeration(closure.reason, CASE_CLOSURE_REASONS, 'Case-response closure reason');
780
+ iso(closure.createdAt, 'Case-response closure time');
781
+ strings(closure.limitations, 'Case-response closure limitations', 8, 240);
782
+ }
783
+ strings(lifecycle.limitations, 'Case-response lifecycle limitations', 8, 600);
784
+ const reviewedInputs = {
785
+ contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
786
+ version: version === CASE_RESPONSE_PACKET_VERSION
787
+ ? CASE_RESPONSE_REVIEW_INPUTS_VERSION
788
+ : version === OBJECT_RESPONSE_CASE_PACKET_VERSION ? OBJECT_RESPONSE_CASE_REVIEW_INPUTS_VERSION
789
+ : version === EVIDENCE_FOLLOW_UP_CASE_RESPONSE_PACKET_VERSION ? EVIDENCE_FOLLOW_UP_CASE_RESPONSE_REVIEW_INPUTS_VERSION
790
+ : version === LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION ? LATEST_PUBLIC_CASE_RESPONSE_REVIEW_INPUTS_VERSION
791
+ : hasPlatformRoutes ? PUBLISHED_V2_3_CASE_RESPONSE_REVIEW_INPUTS_VERSION
792
+ : hasActionBinding ? PUBLISHED_V2_2_CASE_RESPONSE_REVIEW_INPUTS_VERSION : PUBLISHED_V2_CASE_RESPONSE_REVIEW_INPUTS_VERSION,
793
+ profile: {
794
+ id: profile.id,
795
+ label: profile.label,
796
+ audience: profile.audience,
797
+ subject: profile.subject,
798
+ checklist: profile.checklist,
799
+ includedEvidence: profile.includedEvidence,
800
+ excludedEvidence: profile.excludedEvidence,
801
+ redactions: profile.redactions,
802
+ },
803
+ case: root.case,
804
+ incident: root.incident,
805
+ contacts: root.contacts,
806
+ ...(hasActionBinding ? { recipientRoute: root.recipientRoute, actionBinding: root.actionBinding } : {}),
807
+ selectedEvidence: root.selectedEvidence,
808
+ contradictions: root.contradictions,
809
+ readiness: root.readiness,
810
+ artefactReferences: root.artefactReferences,
811
+ escalationHistory: root.escalationHistory,
812
+ escalationHistoryOmitted: root.escalationHistoryOmitted,
813
+ escalationHistoryLimitations: root.escalationHistoryLimitations,
814
+ responseLifecycle: root.responseLifecycle,
815
+ ...(version >= 13 ? { sourceQualifications: strings(root.provenance.limitations, 'Case-response limitations', 8, 600).filter(value => value === CASE_SELECTED_EVIDENCE_SOURCE_LIMITATION) } : {}),
816
+ };
817
+ const provenance = exact(root.provenance, ['latestEvidenceCapturedAt', 'evidencePinCount', 'decisionCount', 'assertionCount', 'observationAge', 'limitations'], 'Case-response provenance');
818
+ iso(provenance.latestEvidenceCapturedAt, 'Case-response evidence time', true);
819
+ integer(provenance.evidencePinCount, 'Case-response evidence pin count', 0, MAX_CASE_EVIDENCE_PINS);
820
+ integer(provenance.decisionCount, 'Case-response decision count', 0, MAX_CASE_DECISIONS);
821
+ integer(provenance.assertionCount, 'Case-response assertion count', 0, MAX_CASE_ASSERTIONS);
822
+ const age = exact(provenance.observationAge, ['ageSeconds', 'band', 'refreshRecommended'], 'Case-response observation age');
823
+ integer(age.ageSeconds, 'Case-response observation age seconds', Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER);
824
+ enumeration(age.band, ['future_or_clock_skew', 'one_to_seven_days', 'over_seven_days', 'under_24_hours'], 'Case-response observation age band');
825
+ boolean(age.refreshRecommended, 'Case-response refresh recommendation');
826
+ const expectedAge = expectedObservationAge(incident.observedAt, root.generatedAt);
827
+ if (age.ageSeconds !== expectedAge.ageSeconds || age.band !== expectedAge.band || age.refreshRecommended !== expectedAge.refreshRecommended)
828
+ fail('Case-response observation age');
829
+ strings(provenance.limitations, 'Case-response limitations', 8, 600);
830
+ const integrity = exact(root.integrity, ['algorithm', 'canonicalization', 'scope', 'digestSha256'], 'Case-response integrity');
831
+ if (integrity.algorithm !== 'SHA-256' || integrity.canonicalization !== 'sorted-json-v2' || integrity.scope !== 'packet excluding integrity')
832
+ fail('Case-response integrity');
833
+ if (typeof integrity.digestSha256 !== 'string' || !HEX_DIGEST_RE.test(integrity.digestSha256))
834
+ fail('Case-response integrity');
835
+ return { canonicalInputs: canonicalArtifactJsonV2(reviewedInputs), expectedDigestSha256: authorisation.reviewedInputDigestSha256 };
836
+ }
837
+ /** Shared semantic pass. The caller must finish the returned digest check and
838
+ * outer packet integrity before treating any input as admitted. */
839
+ export function inspectCaseResponsePacketSemantics(value) {
840
+ if (value.schema !== CASE_RESPONSE_PACKET_SCHEMA)
841
+ fail('Case-response packet schema');
842
+ if (value.schemaVersion === OBJECT_RESPONSE_CASE_PACKET_VERSION)
843
+ return validateVersionedCaseResponsePacket(value, OBJECT_RESPONSE_CASE_PACKET_VERSION);
844
+ if (value.schemaVersion === EVIDENCE_FOLLOW_UP_CASE_RESPONSE_PACKET_VERSION)
845
+ return validateVersionedCaseResponsePacket(value, EVIDENCE_FOLLOW_UP_CASE_RESPONSE_PACKET_VERSION);
846
+ if (value.schemaVersion === LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION)
847
+ return validateVersionedCaseResponsePacket(value, LATEST_PUBLIC_CASE_RESPONSE_PACKET_VERSION);
848
+ if (value.schemaVersion === PUBLIC_CASE_RESPONSE_PACKET_VERSION) {
849
+ validateCaseResponsePacketV6(value);
850
+ return null;
851
+ }
852
+ if (value.schemaVersion === PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION) {
853
+ return validateVersionedCaseResponsePacket(value, PUBLISHED_V2_CASE_RESPONSE_PACKET_VERSION);
854
+ }
855
+ if (value.schemaVersion === PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION) {
856
+ return validateVersionedCaseResponsePacket(value, PUBLISHED_V2_2_CASE_RESPONSE_PACKET_VERSION);
857
+ }
858
+ if (value.schemaVersion === PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION) {
859
+ return validateVersionedCaseResponsePacket(value, PUBLISHED_V2_3_CASE_RESPONSE_PACKET_VERSION);
860
+ }
861
+ if (Number.isSafeInteger(value.schemaVersion) && value.schemaVersion < CASE_RESPONSE_PACKET_VERSION) {
862
+ throw new TypeError(`Case-response packet version ${String(value.schemaVersion)} is not part of the public compatibility boundary; no data was changed.`);
863
+ }
864
+ if (Number.isSafeInteger(value.schemaVersion) && value.schemaVersion > CASE_RESPONSE_PACKET_VERSION) {
865
+ throw new TypeError(`Case-response packet version ${String(value.schemaVersion)} is newer than the supported version ${CASE_RESPONSE_PACKET_VERSION}; no data was changed.`);
866
+ }
867
+ return validateVersionedCaseResponsePacket(value, CASE_RESPONSE_PACKET_VERSION);
868
+ }
869
+ /** Complete semantic and integrity validation for an already byte-bounded,
870
+ * parsed packet. No normalisation, historical upgrade or mutation is performed. */
871
+ export async function validateCaseResponsePacket(value) {
872
+ assertBoundedJsonStructure(value, 'Case-response packet');
873
+ const pending = inspectCaseResponsePacketSemantics(value);
874
+ if (pending) {
875
+ const digest = await sha256ArtifactBytes(new TextEncoder().encode(pending.canonicalInputs));
876
+ assertCaseResponseReviewedInputDigest(pending, digest.slice('sha256:'.length));
877
+ }
878
+ if (!await verifyCaseResponsePacketIntegrity(value))
879
+ throw new TypeError('The case-response packet failed its manifest integrity check.');
880
+ }