@slicedearth/whoisleuth-cli 2.5.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +1 -1
- package/cli/artifact-validation/case-response.mjs +51 -8
- package/cli/artifact-validation/lookup-evidence.mjs +15 -1
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +9 -2
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +79 -3
- package/cli/case-pack.mjs +7 -2
- package/cli/command-definition.mjs +2 -1
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/json.mjs +1 -13
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-command-runner.mjs +1 -0
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +14 -8
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/support-command-runner.mjs +3 -3
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +26 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli.md +99 -2
- package/docs/contextual-reviews.md +28 -5
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-report.mjs +1 -0
- package/lib/favicon.mjs +13 -4
- package/lib/html-signals.mjs +6 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-response-contract.mjs +1 -0
- package/lib/operation-budget.mjs +4 -0
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +138 -137
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/package.json +7 -5
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +16 -0
- package/packages/cases/case-evidence-model.mjs +14 -3
- package/packages/cases/case-object-coverage.mjs +33 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +74 -9
- package/packages/cases/case-record-projection.mjs +15 -3
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +30 -0
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet.mjs +30 -2
- package/packages/cases/case-response-review-inputs.mjs +48 -7
- package/packages/cases/case-workflow-metadata.mjs +1 -9
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +260 -42
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +32 -17
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +43 -11
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +231 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +4 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- package/third-party-notices.txt +29 -29
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Explicit object scope, not ownership, collection or proof of remediation.
|
|
2
|
+
import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
|
|
3
|
+
import { OBJECT_RESPONSE_CASE_SCHEMA_VERSION } from "../contracts/case-portability.mjs";
|
|
4
|
+
import { array, enumeration, exact, text } from "../evidence/artifact-structure.mjs";
|
|
5
|
+
export const CASE_RESPONSE_OBJECT_KINDS = ['domain', 'hostname', 'page', 'redirect', 'advertisement', 'social_account', 'social_post', 'messaging', 'storefront', 'app', 'other'];
|
|
6
|
+
export const CASE_RESPONSE_OBJECT_OUTCOMES = ['removed', 'restricted', 'suspended', 'delisted', 'warning', 'transferred', 'restored', 'disputed'];
|
|
7
|
+
export const MAX_CASE_RESPONSE_OBJECT_IDENTIFIER = 1_979;
|
|
8
|
+
export const MAX_CASE_RESPONSE_OBJECTS = 20;
|
|
9
|
+
export function readCaseResponseObjects(value, sourceVersion) {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
if (sourceVersion != null && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
|
|
13
|
+
throw new TypeError('Object-specific response scope requires Case schema 18 or later.');
|
|
14
|
+
const objects = array(value, 'Response objects', MAX_CASE_RESPONSE_OBJECTS).map(item => readCaseResponseObject(item, sourceVersion));
|
|
15
|
+
const keyed = objects.map(object => [JSON.stringify(object), object]);
|
|
16
|
+
if (new Set(keyed.map(([key]) => key)).size !== keyed.length)
|
|
17
|
+
throw new TypeError('Response objects must be unique.');
|
|
18
|
+
return Object.freeze(keyed.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([, object]) => object));
|
|
19
|
+
}
|
|
20
|
+
/** Historical absence stays absent; a declared historical format cannot carry new fields. */
|
|
21
|
+
export function readCaseResponseObject(value, sourceVersion) {
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (sourceVersion != null && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
|
|
25
|
+
throw new TypeError('Object-specific response scope requires Case schema 18 or later.');
|
|
26
|
+
const item = exact(value, ['kind', 'identifier', 'incidentTargetId'], 'Response object');
|
|
27
|
+
const kind = enumeration(item.kind, CASE_RESPONSE_OBJECT_KINDS, 'Response object kind');
|
|
28
|
+
const identifier = text(item.identifier, 'Response object identifier', MAX_CASE_RESPONSE_OBJECT_IDENTIFIER);
|
|
29
|
+
if (!identifier || identifier.trim() !== identifier || /[\u0000-\u001f\u007f]/u.test(identifier))
|
|
30
|
+
throw new TypeError('Response object identifier must be a bounded single-line value.');
|
|
31
|
+
const incidentTargetId = item.incidentTargetId === null ? null : text(item.incidentTargetId, 'Incident object identity', 64);
|
|
32
|
+
if (incidentTargetId !== null && !/^[A-Za-z0-9_-]{1,64}$/u.test(incidentTargetId))
|
|
33
|
+
throw new TypeError('Incident object identity is invalid.');
|
|
34
|
+
if (kind === 'domain' || kind === 'hostname') {
|
|
35
|
+
if (identifier !== identifier.toLowerCase() || !isValidAsciiHostname(identifier) || incidentTargetId !== null)
|
|
36
|
+
throw new TypeError('Domain and hostname scope require a normalised hostname and no incident-link identity.');
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
let url;
|
|
40
|
+
try {
|
|
41
|
+
url = new URL(identifier);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new TypeError('Select an exact retained HTTP(S) incident link for this object.');
|
|
45
|
+
}
|
|
46
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.toString() !== identifier || !incidentTargetId)
|
|
47
|
+
throw new TypeError('Select an exact retained HTTP(S) incident link without credentials for this object.');
|
|
48
|
+
}
|
|
49
|
+
return Object.freeze({ kind, identifier, incidentTargetId });
|
|
50
|
+
}
|
|
51
|
+
export function readCaseResponseObjectOutcome(value, sourceVersion) {
|
|
52
|
+
if (value === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (sourceVersion != null && sourceVersion < OBJECT_RESPONSE_CASE_SCHEMA_VERSION)
|
|
55
|
+
throw new TypeError('Object-specific outcomes require Case schema 18 or later.');
|
|
56
|
+
return enumeration(value, CASE_RESPONSE_OBJECT_OUTCOMES, 'Response object outcome');
|
|
57
|
+
}
|
|
58
|
+
export function sameCaseResponseObject(left, right) {
|
|
59
|
+
return left !== undefined && right !== undefined && left.kind === right.kind && left.identifier === right.identifier && left.incidentTargetId === right.incidentTargetId;
|
|
60
|
+
}
|
|
61
|
+
/** Explicit retained snapshots permit follow-up without retargeting edited links. */
|
|
62
|
+
export function retainedCaseResponseObjects(record) {
|
|
63
|
+
const objects = [...(record.actions ?? []).flatMap(action => [...action.responseObjects ?? [], ...action.history.flatMap(event => event.responseObjects ?? [])]),
|
|
64
|
+
...(record.observedEffects?.reviews ?? []).flatMap(review => review.responseObject ? [review.responseObject] : []),
|
|
65
|
+
...(record.evidencePins ?? []).flatMap(pin => pin.responseObject ? [pin.responseObject] : []),
|
|
66
|
+
...(record.closures?.records ?? []).flatMap(closure => closure.responseObject ? [closure.responseObject] : []),
|
|
67
|
+
...(record.assertions ?? []).flatMap(assertion => assertion.recheck?.responseObject ? [assertion.recheck.responseObject] : [])];
|
|
68
|
+
return [...new Map(objects.map(object => [JSON.stringify(object), object])).values()];
|
|
69
|
+
}
|
|
70
|
+
/** New identities require current metadata; exact historical identities remain usable. */
|
|
71
|
+
export function assertCaseResponseObject(object, record) {
|
|
72
|
+
if (!object)
|
|
73
|
+
return;
|
|
74
|
+
if (object.kind === 'domain') {
|
|
75
|
+
if (object.identifier !== record.domain)
|
|
76
|
+
throw new TypeError('The response domain must match this Case.');
|
|
77
|
+
}
|
|
78
|
+
else if (object.kind === 'hostname') {
|
|
79
|
+
if (object.identifier !== record.domain && !object.identifier.endsWith(`.${record.domain}`))
|
|
80
|
+
throw new TypeError('The response hostname must belong to this Case domain.');
|
|
81
|
+
}
|
|
82
|
+
else if (!record.workflowMetadata?.incidentTargets.some(target => target.id === object.incidentTargetId && target.url === object.identifier)
|
|
83
|
+
&& !retainedCaseResponseObjects(record).some(retained => sameCaseResponseObject(retained, object))) {
|
|
84
|
+
throw new TypeError('The selected exact incident object changed or is not retained in this Case. Refresh and review it before saving.');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function assertCaseObjectOutcome(outcome, object) {
|
|
88
|
+
if (!outcome)
|
|
89
|
+
return;
|
|
90
|
+
if (!object)
|
|
91
|
+
throw new TypeError('A typed object outcome requires explicit object scope.');
|
|
92
|
+
if (outcome === 'suspended' && object.kind !== 'domain')
|
|
93
|
+
throw new TypeError('Domain suspension requires domain scope.');
|
|
94
|
+
if (outcome === 'restricted' && object.kind !== 'social_account' && object.kind !== 'app' && object.kind !== 'other')
|
|
95
|
+
throw new TypeError('Account restriction requires account, app or explicitly other scope.');
|
|
96
|
+
}
|
|
97
|
+
export function caseResponseObjectChoices(record) {
|
|
98
|
+
const objects = [{ kind: 'domain', identifier: record.domain, incidentTargetId: null }];
|
|
99
|
+
const hosts = new Set([...(record.evidencePins ?? []).map(pin => pin.observationHostname), ...(record.evidenceHistory ?? []).map(snapshot => snapshot.inputHostname)].filter((value) => Boolean(value)));
|
|
100
|
+
for (const identifier of hosts)
|
|
101
|
+
if (identifier === record.domain || identifier.endsWith(`.${record.domain}`))
|
|
102
|
+
objects.push({ kind: 'hostname', identifier, incidentTargetId: null });
|
|
103
|
+
for (const target of record.workflowMetadata?.incidentTargets ?? [])
|
|
104
|
+
for (const kind of CASE_RESPONSE_OBJECT_KINDS.filter(kind => kind !== 'domain' && kind !== 'hostname'))
|
|
105
|
+
objects.push({ kind, identifier: target.url, incidentTargetId: target.id });
|
|
106
|
+
objects.push(...retainedCaseResponseObjects(record));
|
|
107
|
+
return [...new Map(objects.map(object => [JSON.stringify(object), object])).values()].map(object => {
|
|
108
|
+
const historical = Boolean(object.incidentTargetId && !record.workflowMetadata?.incidentTargets.some(target => target.id === object.incidentTargetId && target.url === object.identifier));
|
|
109
|
+
return { value: JSON.stringify(object), label: `${object.kind.replaceAll('_', ' ')} · ${object.identifier}${historical ? ' · historical / no current incident link' : ''}`, object };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
export function selectedCaseResponseObject(record, value) {
|
|
113
|
+
if (!value)
|
|
114
|
+
return undefined;
|
|
115
|
+
const object = caseResponseObjectChoices(record).find(choice => choice.value === value)?.object;
|
|
116
|
+
if (!object)
|
|
117
|
+
throw new TypeError('The selected incident object changed. Refresh and review it before saving.');
|
|
118
|
+
return readCaseResponseObject(object);
|
|
119
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Independent observed effects, linked closures and lifecycle summaries.
|
|
2
2
|
import { MAX_CASE_CLOSURES, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, } from "../contracts/case-portability.mjs";
|
|
3
3
|
import { latestObservationCohort, } from "../evidence/latest-observations.mjs";
|
|
4
|
-
import { readCaseRecheckAnswerContext } from "./case-recheck-model.mjs";
|
|
4
|
+
import { readCaseRecheckAnswerContext, assertRecheckNonReproduction, COMPARATIVE_CASE_OBJECT_OUTCOMES } from "./case-recheck-model.mjs";
|
|
5
|
+
import { readCaseResponseObject, readCaseResponseObjectOutcome, assertCaseObjectOutcome, sameCaseResponseObject } from "./case-response-object.mjs";
|
|
5
6
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
6
7
|
import { CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, } from "./case-response-records.mjs";
|
|
7
8
|
import { COMPLETENESS, SAFE_ID_RE, boundedCounter, compareCodeUnits, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
|
|
@@ -20,6 +21,16 @@ function normalizeObservedEffectReview(raw, fallback, validPinIds, validSighting
|
|
|
20
21
|
return null;
|
|
21
22
|
const createdAt = iso(item.createdAt, observedAt || fallback, options);
|
|
22
23
|
const recheck = readCaseRecheckAnswerContext(item.recheck, options.sourceVersion);
|
|
24
|
+
const responseObject = readCaseResponseObject(item.responseObject, options.sourceVersion);
|
|
25
|
+
const objectOutcome = readCaseResponseObjectOutcome(item.objectOutcome, options.sourceVersion);
|
|
26
|
+
assertCaseObjectOutcome(objectOutcome, responseObject);
|
|
27
|
+
if (objectOutcome && (item.state === 'unavailable' || item.state === 'not_checked'))
|
|
28
|
+
throw new TypeError('Unavailable or unchecked collection cannot establish an object outcome.');
|
|
29
|
+
if (objectOutcome && COMPARATIVE_CASE_OBJECT_OUTCOMES.includes(objectOutcome)
|
|
30
|
+
&& (item.completeness !== 'complete' || !recheck?.responseObject || recheck.conditionsMatch !== 'comparable'))
|
|
31
|
+
throw new TypeError('An independently observed object state change requires complete evidence and comparable exact-object conditions.');
|
|
32
|
+
if (recheck?.responseObject && !sameCaseResponseObject(recheck.responseObject, responseObject))
|
|
33
|
+
throw new TypeError('The recheck answer and observation must concern the same exact object.');
|
|
23
34
|
if (recheck && item.state === 'not_reproduced' && (item.completeness !== 'complete' || recheck.conditionsMatch !== 'comparable')) {
|
|
24
35
|
throw new TypeError('A question cannot be marked not reproduced from incomplete evidence or unconfirmed comparison conditions.');
|
|
25
36
|
}
|
|
@@ -51,6 +62,8 @@ function normalizeObservedEffectReview(raw, fallback, validPinIds, validSighting
|
|
|
51
62
|
sightingId,
|
|
52
63
|
followUpAt: optionalIso(item.followUpAt, options),
|
|
53
64
|
...(recheck ? { recheck } : {}),
|
|
65
|
+
...(responseObject ? { responseObject } : {}),
|
|
66
|
+
...(objectOutcome ? { objectOutcome } : {}),
|
|
54
67
|
createdAt,
|
|
55
68
|
};
|
|
56
69
|
}
|
|
@@ -128,6 +141,7 @@ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options
|
|
|
128
141
|
if (!summary)
|
|
129
142
|
return null;
|
|
130
143
|
const createdAt = iso(item.createdAt, fallback, options);
|
|
144
|
+
const responseObject = readCaseResponseObject(item.responseObject, options.sourceVersion);
|
|
131
145
|
const observedEffectReviewId = typeof item.observedEffectReviewId === 'string'
|
|
132
146
|
&& SAFE_ID_RE.test(item.observedEffectReviewId)
|
|
133
147
|
&& (!validReviewIds || validReviewIds.has(item.observedEffectReviewId))
|
|
@@ -150,7 +164,8 @@ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options
|
|
|
150
164
|
return null;
|
|
151
165
|
const linkedProviderEvents = actionId ? linkContext.providerResolutionEvents?.get(actionId) ?? [] : [];
|
|
152
166
|
if (linkContext.providerResolutionEvents && reason === 'provider_reported_resolution_not_independently_checked'
|
|
153
|
-
&& !linkedProviderEvents.some((event) => Date.parse(event.occurredAt) <= Date.parse(createdAt)
|
|
167
|
+
&& !linkedProviderEvents.some((event) => Date.parse(event.occurredAt) <= Date.parse(createdAt)
|
|
168
|
+
&& (responseObject ? event.responseObjects?.some(object => sameCaseResponseObject(object, responseObject)) : !event.responseObjects?.length)))
|
|
154
169
|
return null;
|
|
155
170
|
const linkLimitations = [
|
|
156
171
|
...(item.observedEffectReviewId != null && !observedEffectReviewId ? ['A malformed or dangling observed-effect review reference was omitted from this closure.'] : []),
|
|
@@ -158,6 +173,7 @@ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options
|
|
|
158
173
|
];
|
|
159
174
|
return {
|
|
160
175
|
id: safeId(item.id, 'case-closure', { reason: item.reason, summary, createdAt }),
|
|
176
|
+
...(responseObject === undefined ? {} : { responseObject }),
|
|
161
177
|
reason,
|
|
162
178
|
summary,
|
|
163
179
|
observedEffectReviewId,
|
|
@@ -174,8 +190,11 @@ export function buildCaseClosureLinkContext(observedEffects, actions) {
|
|
|
174
190
|
createdAt: review.createdAt,
|
|
175
191
|
}])),
|
|
176
192
|
providerResolutionEvents: new Map(actions.map((action) => [action.id, action.history
|
|
177
|
-
|
|
178
|
-
|
|
193
|
+
// Reconciliation can change projection, but not the identity of a retained
|
|
194
|
+
// typed receipt supporting an already-authored historical decision.
|
|
195
|
+
.filter((event) => event.providerOutcome === 'provider_reports_resolved')
|
|
196
|
+
.map((event) => ({ eventId: event.id, occurredAt: event.occurredAt,
|
|
197
|
+
...(event.responseObjects === undefined ? {} : { responseObjects: event.responseObjects }) }))])),
|
|
179
198
|
};
|
|
180
199
|
}
|
|
181
200
|
export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, validActionIds, options = {}, linkContext = {}) {
|
|
@@ -224,7 +243,9 @@ export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, valid
|
|
|
224
243
|
};
|
|
225
244
|
}
|
|
226
245
|
/** New-write policy only. Historical normalisation must not re-adjudicate a closure. */
|
|
227
|
-
export function caseClosureReviewBlocker(reason, review, now) {
|
|
246
|
+
export function caseClosureReviewBlocker(reason, review, now, responseObject, evidencePins = []) {
|
|
247
|
+
if (review?.responseObject && !sameCaseResponseObject(responseObject, review.responseObject))
|
|
248
|
+
return 'This independent review concerns one object. Select that object for closure; it cannot close the whole Case.';
|
|
228
249
|
if (reason !== 'independently_not_reproduced' && reason !== 'infrastructure_changed')
|
|
229
250
|
return null;
|
|
230
251
|
const expected = reason === 'independently_not_reproduced' ? 'not_reproduced' : 'changed';
|
|
@@ -233,6 +254,18 @@ export function caseClosureReviewBlocker(reason, review, now) {
|
|
|
233
254
|
: 'This closure reason requires a linked independent changed review.';
|
|
234
255
|
if (!review || review.state !== expected)
|
|
235
256
|
return invalidLink;
|
|
257
|
+
if (responseObject) {
|
|
258
|
+
if (!sameCaseResponseObject(responseObject, review.responseObject))
|
|
259
|
+
return 'Object-specific technical closure requires a linked independent review explicitly bound to this exact object; historical missing binding remains unknown.';
|
|
260
|
+
if (!review.recheck || !sameCaseResponseObject(responseObject, review.recheck.responseObject))
|
|
261
|
+
return 'Object-specific technical closure requires a complete exact-object baseline and current review under comparable conditions.';
|
|
262
|
+
try {
|
|
263
|
+
assertRecheckNonReproduction('not_reproduced', review.recheck, review.completeness, evidencePins, evidencePins.find(pin => pin.id === review.evidencePinId), review.observedAt);
|
|
264
|
+
}
|
|
265
|
+
catch (cause) {
|
|
266
|
+
return cause instanceof Error ? cause.message : 'The exact-object review cannot support technical closure.';
|
|
267
|
+
}
|
|
268
|
+
}
|
|
236
269
|
const observedAt = normalizeExplicitIsoTimestamp(review.observedAt);
|
|
237
270
|
const createdAt = normalizeExplicitIsoTimestamp(review.createdAt);
|
|
238
271
|
const closedAt = normalizeExplicitIsoTimestamp(now);
|
|
@@ -248,7 +281,43 @@ export function caseClosureReviewBlocker(reason, review, now) {
|
|
|
248
281
|
}
|
|
249
282
|
return null;
|
|
250
283
|
}
|
|
251
|
-
|
|
284
|
+
/** Latest applicable provider cohort, never the action-wide summary or an old receipt. */
|
|
285
|
+
export function caseClosureProviderBlocker(action, responseObject, now) {
|
|
286
|
+
const blocked = 'This closure reason requires a linked typed provider-reported-resolution outcome for the latest applicable object observation.';
|
|
287
|
+
const closedAt = normalizeExplicitIsoTimestamp(now);
|
|
288
|
+
if (!action || !closedAt)
|
|
289
|
+
return blocked;
|
|
290
|
+
if (responseObject ? !action.responseObjects?.some(object => sameCaseResponseObject(object, responseObject)) : action.responseObjects?.length)
|
|
291
|
+
return blocked;
|
|
292
|
+
// A losing workflow transition is still retained evidence. Select the
|
|
293
|
+
// exact-object cohort before checking whether its receipts were applied.
|
|
294
|
+
const events = action.history.filter(event => (event.providerOutcome !== null || event.objectOutcome !== undefined)
|
|
295
|
+
&& Date.parse(event.occurredAt) <= Date.parse(closedAt)
|
|
296
|
+
&& (responseObject ? event.responseObjects?.some(object => sameCaseResponseObject(object, responseObject)) : !event.responseObjects?.length));
|
|
297
|
+
const cohort = latestObservationCohort(events, event => event.occurredAt);
|
|
298
|
+
if (!cohort.latest.length || cohort.undated.length || cohort.latest.some(event => !event.applied || event.providerOutcome !== 'provider_reports_resolved'
|
|
299
|
+
|| event.objectOutcome === 'restored' || event.objectOutcome === 'disputed')
|
|
300
|
+
|| new Set(cohort.latest.map(event => event.objectOutcome ?? null)).size !== 1)
|
|
301
|
+
return blocked;
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
export function caseClosureActionBlocker(reason, action, responseObject, now) {
|
|
305
|
+
if (action?.responseObjects?.length && (!responseObject || !action.responseObjects.some(object => sameCaseResponseObject(object, responseObject))))
|
|
306
|
+
return 'This action concerns explicitly bound objects. Select one of them for this closure; other objects remain independent.';
|
|
307
|
+
return reason === 'provider_reported_resolution_not_independently_checked' ? caseClosureProviderBlocker(action, responseObject, now) : null;
|
|
308
|
+
}
|
|
309
|
+
/** Derived presentation only: never rewrite the analyst's summary or limitations. */
|
|
310
|
+
export function caseClosureHistoryQualification(closure, actions) {
|
|
311
|
+
if (closure.reason !== 'provider_reported_resolution_not_independently_checked')
|
|
312
|
+
return null;
|
|
313
|
+
const action = actions.find(candidate => candidate.id === closure.actionId);
|
|
314
|
+
// Evaluate the retained history, not a wall-clock freshness claim. Future-dated
|
|
315
|
+
// or conflicting receipts cannot silently make an old decision authoritative.
|
|
316
|
+
const latestAt = (action?.history ?? []).reduce((latest, event) => Date.parse(event.occurredAt) > Date.parse(latest) ? event.occurredAt : latest, closure.createdAt);
|
|
317
|
+
return caseClosureProviderBlocker(action, closure.responseObject, latestAt) === null ? null
|
|
318
|
+
: 'As of the latest retained receipt, provider history does not support a new closure for this scope. This historical analyst decision is preserved; it does not establish current remediation.';
|
|
319
|
+
}
|
|
320
|
+
export function appendCaseClosure(current, raw, now, observedEffects, actions, evidencePins = []) {
|
|
252
321
|
const item = record(raw);
|
|
253
322
|
const reason = typeof item.reason === 'string' && CLOSURE_REASONS.has(item.reason)
|
|
254
323
|
? item.reason
|
|
@@ -259,16 +328,13 @@ export function appendCaseClosure(current, raw, now, observedEffects, actions) {
|
|
|
259
328
|
const action = typeof item.actionId === 'string'
|
|
260
329
|
? actions.find((candidate) => candidate.id === item.actionId) ?? null
|
|
261
330
|
: null;
|
|
262
|
-
const
|
|
331
|
+
const responseObject = readCaseResponseObject(item.responseObject);
|
|
332
|
+
const reviewBlocker = caseClosureReviewBlocker(reason, review, now, responseObject, evidencePins);
|
|
263
333
|
if (reviewBlocker)
|
|
264
334
|
throw new Error(reviewBlocker);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
&& event.providerOutcome === 'provider_reports_resolved'
|
|
269
|
-
&& Date.parse(event.occurredAt) <= Date.parse(now)))) {
|
|
270
|
-
throw new Error('This closure reason requires a linked typed provider-reported-resolution outcome.');
|
|
271
|
-
}
|
|
335
|
+
const actionBlocker = caseClosureActionBlocker(reason, action, responseObject, now);
|
|
336
|
+
if (actionBlocker)
|
|
337
|
+
throw new Error(actionBlocker);
|
|
272
338
|
const linkContext = buildCaseClosureLinkContext(observedEffects, actions);
|
|
273
339
|
const created = normalizeClosure({ ...item, id: freshId('case-closure'), createdAt: now }, now, new Set(observedEffects.reviews.map((candidate) => candidate.id)), new Set(actions.map((candidate) => candidate.id)), {}, linkContext);
|
|
274
340
|
if (!created || !reason)
|
|
@@ -314,6 +380,7 @@ export function buildCaseResponseLifecycleSummary(input) {
|
|
|
314
380
|
? 'missing'
|
|
315
381
|
: latestObservedChangeAt ? 'available' : 'ambiguous';
|
|
316
382
|
const latestClosure = [...(input.closures?.records ?? [])]
|
|
383
|
+
.filter(closure => closure.responseObject === undefined)
|
|
317
384
|
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || compareCodeUnits(right.id, left.id))[0] ?? null;
|
|
318
385
|
return {
|
|
319
386
|
providerOutcomeState,
|
|
@@ -3,6 +3,7 @@ import { escapeCaseMarkdownInline as escapeMarkdown } from "./case-markdown.mjs"
|
|
|
3
3
|
/** Render only the projected packet, never the source Case or private inputs. */
|
|
4
4
|
export function renderCaseResponsePacket(packet) {
|
|
5
5
|
const { profile, case: caseRecord, incident: { category, affectedParty, abusiveUrls, observedHarm, observedAt }, generatedAt: normalizedGeneratedAt, recipientRoute, escalationHistory, escalationHistoryOmitted, escalationHistoryLimitations, authorisation, readiness, selectedEvidence, artefactReferences, responseLifecycle, preflight, provenance: { limitations, evidencePinCount, decisionCount, assertionCount, observationAge: age }, integrity: { digestSha256 }, } = packet;
|
|
6
|
+
const selectedCorrection = escalationHistory.find(action => action.actionId === packet.actionBinding.selectedActionId)?.correction;
|
|
6
7
|
const lines = [
|
|
7
8
|
`# ${escapeMarkdown(profile.label)} packet`,
|
|
8
9
|
'',
|
|
@@ -14,7 +15,7 @@ export function renderCaseResponsePacket(packet) {
|
|
|
14
15
|
`**Audience:** ${escapeMarkdown(profile.audience)}`,
|
|
15
16
|
`**Suggested subject:** ${escapeMarkdown(profile.subject)}`,
|
|
16
17
|
'',
|
|
17
|
-
'## Observed harm',
|
|
18
|
+
selectedCorrection ? '## Retained incident context (not a renewed allegation)' : '## Observed harm',
|
|
18
19
|
'',
|
|
19
20
|
escapeMarkdown(observedHarm),
|
|
20
21
|
'',
|
|
@@ -48,9 +49,14 @@ export function renderCaseResponsePacket(packet) {
|
|
|
48
49
|
...(action.outcomeDetail ? [` - Outcome detail: ${escapeMarkdown(action.outcomeDetail)}`] : []),
|
|
49
50
|
...(action.originActionId ? [` - Originating action: ${escapeMarkdown(action.originActionId)}`] : []),
|
|
50
51
|
...(action.amendment ? [` - Amendment of submitted packet SHA-256: ${action.amendment.packetDigestSha256}`, ` - Prepared request events: ${action.amendment.requestEventIds.map(escapeMarkdown).join(', ')}`] : []),
|
|
52
|
+
...(action.correction ? [` - ${action.correction.purpose === 'correction' ? 'Correction request' : 'Retraction request'} for delivery event ${escapeMarkdown(action.correction.deliveryEventId)}; original packet v${action.correction.packetVersion} SHA-256: ${action.correction.packetDigestSha256}`,
|
|
53
|
+
` - Analyst reason: ${escapeMarkdown(action.correction.reason)}`, ` - Previous statement: ${escapeMarkdown(action.correction.previousStatement)}`,
|
|
54
|
+
` - Corrected statement: ${escapeMarkdown(action.correction.correctedStatement || 'Request to retract the previous statement')}`,
|
|
55
|
+
` - Corrected evidence pins: ${action.correction.evidencePinIds.map(escapeMarkdown).join(', ')}`] : []),
|
|
51
56
|
...action.transitions.flatMap((event) => [
|
|
52
57
|
` - ${event.occurredAt}: ${escapeMarkdown(event.previousState ?? 'none')} → ${escapeMarkdown(event.nextState)} · ${escapeMarkdown(event.sourceClass)} · ${escapeMarkdown(event.provenance)}${event.providerOutcome ? ` · ${escapeMarkdown(event.providerOutcome.replaceAll('_', ' '))}` : ''}${event.applied ? '' : ' · retained conflict'}`,
|
|
53
58
|
...(event.reference ? [` - Reference: ${escapeMarkdown(event.reference)}`] : []),
|
|
59
|
+
...(event.packetReceipt ? [` - Exact local packet receipt: v${event.packetReceipt.packetVersion} · ${event.packetReceipt.packetDigestSha256} · ${escapeMarkdown(event.packetReceipt.recipient)} · ${escapeMarkdown(event.packetReceipt.profile)} · generated ${event.packetReceipt.packetGeneratedAt}`] : []),
|
|
54
60
|
...(event.evidencePinId ? [` - Evidence pin: ${escapeMarkdown(event.evidencePinId)}`] : []),
|
|
55
61
|
...(event.evidenceRequest ? [
|
|
56
62
|
` - Requested evidence (${event.evidenceRequest.state}): ${escapeMarkdown(event.evidenceRequest.summary)}`,
|
|
@@ -86,6 +92,10 @@ export function renderCaseResponsePacket(packet) {
|
|
|
86
92
|
'',
|
|
87
93
|
'## Provider outcome and independent effect',
|
|
88
94
|
'',
|
|
95
|
+
...escalationHistory.flatMap(action => [
|
|
96
|
+
`- Action ${escapeMarkdown(action.actionId)} object scope: ${action.responseObjects?.length ? action.responseObjects.map(object => `${escapeMarkdown(object.kind)} · ${escapeMarkdown(object.identifier)}`).join('; ') : 'unknown; no exact-object coverage inferred'}`,
|
|
97
|
+
...action.transitions.filter(event => event.objectOutcome).map(event => ` - ${escapeMarkdown(event.objectOutcome)} · ${escapeMarkdown(event.sourceClass)} · ${event.occurredAt} · affected objects: ${event.responseObjects?.map(object => `${escapeMarkdown(object.kind)} · ${escapeMarkdown(object.identifier)}`).join('; ')}${event.applied ? '' : ' · retained conflict'}`),
|
|
98
|
+
]),
|
|
89
99
|
responseLifecycle.latestProviderOutcome
|
|
90
100
|
? `- Provider outcome time: ${responseLifecycle.latestProviderOutcome.occurredAt} (${escapeMarkdown(responseLifecycle.latestProviderOutcome.outcome.replaceAll('_', ' '))})`
|
|
91
101
|
: `- Provider outcome time: Withheld because the typed event state is ${responseLifecycle.providerOutcomeState}.`,
|
|
@@ -93,6 +103,7 @@ export function renderCaseResponsePacket(packet) {
|
|
|
93
103
|
? `- Independently observed change time: ${responseLifecycle.latestObservedChangeAt}`
|
|
94
104
|
: `- Independently observed change time: Withheld because the independent change state is ${responseLifecycle.observedChangeState}.`,
|
|
95
105
|
...(responseLifecycle.latestObservedEffect ? [`- Latest independent review: ${escapeMarkdown(responseLifecycle.latestObservedEffect.state.replaceAll('_', ' '))} · ${responseLifecycle.latestObservedEffect.observedAt} · ${escapeMarkdown(responseLifecycle.latestObservedEffect.source)}`] : []),
|
|
106
|
+
...(responseLifecycle.latestObservedEffect?.responseObject ? [`- Reviewed object: ${escapeMarkdown(responseLifecycle.latestObservedEffect.responseObject.kind)} · ${escapeMarkdown(responseLifecycle.latestObservedEffect.responseObject.identifier)}${responseLifecycle.latestObservedEffect.objectOutcome ? ` · ${escapeMarkdown(responseLifecycle.latestObservedEffect.objectOutcome)}` : ''}`] : []),
|
|
96
107
|
...responseLifecycle.limitations.map((limitation) => `- ${escapeMarkdown(limitation)}`),
|
|
97
108
|
'',
|
|
98
109
|
'## Review and provenance',
|
|
@@ -117,22 +128,29 @@ export function renderCaseResponsePacket(packet) {
|
|
|
117
128
|
...profile.followUpFields.map((item) => `- Follow-up field: ${escapeMarkdown(item)}`),
|
|
118
129
|
];
|
|
119
130
|
const markdown = `${lines.join('\n').trim()}\n`;
|
|
131
|
+
const correction = escalationHistory.find(action => action.actionId === packet.actionBinding.selectedActionId)?.correction;
|
|
120
132
|
const email = [
|
|
121
133
|
`Subject: ${profile.subject}`,
|
|
122
134
|
'',
|
|
123
135
|
'Hello,',
|
|
124
136
|
'',
|
|
125
|
-
`I am
|
|
137
|
+
correction ? `I am requesting ${correction.purpose === 'correction' ? 'a correction' : 'retraction or withdrawal'} of a previous statement involving ${caseRecord.domain}.`
|
|
138
|
+
: `I am reporting observed ${category} activity involving ${caseRecord.domain}.`,
|
|
139
|
+
...(correction ? [`Original delivery event: ${correction.deliveryEventId}`, `Original canonical packet v${correction.packetVersion} SHA-256: ${correction.packetDigestSha256}`,
|
|
140
|
+
`Analyst reason: ${correction.reason}`, `Previous statement: ${correction.previousStatement}`,
|
|
141
|
+
`Corrected statement: ${correction.correctedStatement || 'Please retract the previous statement.'}`,
|
|
142
|
+
`Corrected evidence references: ${correction.evidencePinIds.join(', ')}`, 'This new request does not establish delivery, recipient acceptance, reversal, restoration or independent recheck.', ''] : []),
|
|
126
143
|
`Affected party: ${affectedParty}`,
|
|
127
144
|
`Observed at (UTC): ${observedAt}`,
|
|
128
145
|
'',
|
|
129
|
-
'Observed harm:',
|
|
146
|
+
correction ? 'Retained incident context (not a renewed allegation):' : 'Observed harm:',
|
|
130
147
|
observedHarm,
|
|
131
148
|
'',
|
|
132
149
|
'Exact URLs:',
|
|
133
150
|
...abusiveUrls.map((url) => `- ${url}`),
|
|
134
151
|
'',
|
|
135
152
|
'Selected evidence:',
|
|
153
|
+
...escalationHistory.flatMap(action => action.responseObjects?.length ? [`Action ${action.actionId} scope: ${action.responseObjects.map(object => `${object.kind} · ${object.identifier}`).join('; ')}`] : []),
|
|
136
154
|
...(selectedEvidence.length
|
|
137
155
|
? selectedEvidence.map((item) => `- ${item.label} — ${item.source}${item.observationHostname ? ` for ${item.observationHostname}` : ''}${item.webObservationMode ? ' · selected URL' : ''}, observed ${item.observedAt ?? 'time unavailable'} (${item.completeness})`)
|
|
138
156
|
: ['- No Case evidence pin was selected.']),
|
|
@@ -148,6 +166,7 @@ export function renderCaseResponsePacket(packet) {
|
|
|
148
166
|
: []),
|
|
149
167
|
'',
|
|
150
168
|
'Please review this report under the applicable abuse and acceptable-use policies.',
|
|
169
|
+
...limitations,
|
|
151
170
|
'',
|
|
152
171
|
authorisation.status === 'authorised'
|
|
153
172
|
? 'This locally prepared packet is bound to explicit review confirmations. It was not submitted automatically and does not promise any provider outcome.'
|
|
@@ -3,6 +3,9 @@ import { responseContactLabel as contactLabel } from "./case-response-packet-voc
|
|
|
3
3
|
// Pure abuse-evidence packet builder. It creates local review artifacts only:
|
|
4
4
|
// no network requests, mailto links, submissions, or provider side effects.
|
|
5
5
|
import { assertPacketAmendmentSelection } from "./case-requested-evidence.mjs";
|
|
6
|
+
import { correctionDelivery, CASE_DELIVERY_RECEIPT_LIMITATION } from "./case-packet-correction.mjs";
|
|
7
|
+
import { caseSelectedEvidenceSourceLimitations } from "./case-evidence-links.mjs";
|
|
8
|
+
import { sameCaseResponseObject } from "./case-response-object.mjs";
|
|
6
9
|
import { assertBoundedJsonStructure, } from "../analysis/bounded-json.mjs";
|
|
7
10
|
import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_REVIEW_INPUTS_SCHEMA, CASE_RESPONSE_REVIEW_INPUTS_VERSION, MAX_ABUSE_CATEGORY_LENGTH, MAX_ABUSIVE_URLS, MAX_AFFECTED_PARTY_LENGTH, MAX_EXACT_URL_LENGTH, MAX_RESPONSE_ACTION_HISTORY, MAX_RESPONSE_ARTEFACT_REFERENCES, MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, MAX_RESPONSE_CONTRADICTIONS, MAX_RESPONSE_HARM_LENGTH, MAX_RESPONSE_SELECTED_EVIDENCE, SUPPORTED_CASE_RESPONSE_PACKET_VERSIONS, } from "../contracts/case-portability.mjs";
|
|
8
11
|
import { SORTED_JSON_V2, canonicalArtifactJsonV2, } from "../evidence/artifact-integrity.mjs";
|
|
@@ -222,6 +225,9 @@ function assertAmendmentEvidence(caseRecord, input) {
|
|
|
222
225
|
const action = selectedPacketAction(caseRecord, input);
|
|
223
226
|
const selected = new Set(Array.isArray(input.selectedEvidencePinIds) ? input.selectedEvidencePinIds : []);
|
|
224
227
|
assertPacketAmendmentSelection(caseRecord.actions, action?.id ?? null, new Set(caseRecord.evidencePins.filter(pin => selected.has(pin.id)).map(pin => pin.id)));
|
|
228
|
+
if (action?.correction)
|
|
229
|
+
correctionDelivery(caseRecord.actions, action, { caseId: caseRecord.id, target: caseRecord.domain,
|
|
230
|
+
profile: responsePacketProfile(input.profile).id, selectedPinIds: new Set(caseRecord.evidencePins.filter(pin => selected.has(pin.id)).map(pin => pin.id)) });
|
|
225
231
|
}
|
|
226
232
|
function packetActionLineage(caseRecord, input) {
|
|
227
233
|
const selected = selectedPacketAction(caseRecord, input);
|
|
@@ -322,6 +328,7 @@ function normalizeSelectedEvidence(caseRecord, value) {
|
|
|
322
328
|
label: text(pin.label, 80),
|
|
323
329
|
source: text(pin.source, 120),
|
|
324
330
|
...(pin.observationHostname ? { observationHostname: pin.observationHostname } : {}),
|
|
331
|
+
...(pin.responseObject ? { responseObject: structuredClone(pin.responseObject) } : {}),
|
|
325
332
|
...(pin.webObservationMode ? { webObservationMode: pin.webObservationMode } : {}),
|
|
326
333
|
observedAt: timestamp(pin.observedAt),
|
|
327
334
|
completeness: text(pin.completeness, 40),
|
|
@@ -524,7 +531,7 @@ export function buildResponsePacketProfilePreview(caseRecord, input) {
|
|
|
524
531
|
id: profile.id,
|
|
525
532
|
label: profile.label,
|
|
526
533
|
audience: profile.audience,
|
|
527
|
-
subject: `${profile.subjectPrefix}: ${caseRecord.domain} (${category})`,
|
|
534
|
+
subject: `${selectedPacketAction(caseRecord, input)?.correction ? 'Correction or retraction request' : profile.subjectPrefix}: ${caseRecord.domain} (${category})`,
|
|
528
535
|
checklist: [...profile.checklist],
|
|
529
536
|
evidenceOrder: [...profile.evidenceOrder],
|
|
530
537
|
includedEvidence: [...profile.includedEvidence],
|
|
@@ -687,6 +694,8 @@ function normalizeActionHistory(caseRecord, input) {
|
|
|
687
694
|
actionId: action.id,
|
|
688
695
|
type: text(action.type, 80),
|
|
689
696
|
...(action.amendment ? { amendment: structuredClone(action.amendment) } : {}),
|
|
697
|
+
...(action.correction ? { correction: structuredClone(action.correction) } : {}),
|
|
698
|
+
...(action.responseObjects !== undefined ? { responseObjects: structuredClone(action.responseObjects) } : {}),
|
|
690
699
|
recipient: text(action.recipient, 320),
|
|
691
700
|
contactSource: text(action.contactSource, 120),
|
|
692
701
|
routeObservedAt: timestamp(action.routeObservedAt),
|
|
@@ -701,6 +710,9 @@ function normalizeActionHistory(caseRecord, input) {
|
|
|
701
710
|
transitions: action.history.map((event) => ({
|
|
702
711
|
id: event.id,
|
|
703
712
|
...(event.evidenceRequest ? { evidenceRequest: structuredClone(event.evidenceRequest) } : {}),
|
|
713
|
+
...(event.packetReceipt ? { packetReceipt: structuredClone(event.packetReceipt) } : {}),
|
|
714
|
+
...(event.responseObjects !== undefined ? { responseObjects: structuredClone(event.responseObjects) } : {}),
|
|
715
|
+
...(event.objectOutcome ? { objectOutcome: event.objectOutcome } : {}),
|
|
704
716
|
previousState: event.previousState,
|
|
705
717
|
nextState: event.nextState,
|
|
706
718
|
occurredAt: event.occurredAt,
|
|
@@ -736,21 +748,31 @@ function normalizeActionHistory(caseRecord, input) {
|
|
|
736
748
|
}
|
|
737
749
|
function normalizeResponseLifecycle(caseRecord, actionIds, lineageComplete) {
|
|
738
750
|
const scopedIds = new Set(actionIds);
|
|
751
|
+
const objects = caseRecord.actions.filter(action => scopedIds.has(action.id)).flatMap(action => action.responseObjects ?? []);
|
|
739
752
|
const scopedRecord = {
|
|
740
753
|
...caseRecord,
|
|
741
754
|
actions: caseRecord.actions.filter((action) => scopedIds.has(action.id)),
|
|
755
|
+
observedEffects: {
|
|
756
|
+
...caseRecord.observedEffects,
|
|
757
|
+
reviews: caseRecord.observedEffects.reviews.filter(review => review.responseObject
|
|
758
|
+
&& objects.some(object => sameCaseResponseObject(object, review.responseObject))),
|
|
759
|
+
},
|
|
742
760
|
closures: {
|
|
743
761
|
...caseRecord.closures,
|
|
744
762
|
records: caseRecord.closures.records.filter((closure) => closure.actionId !== null && scopedIds.has(closure.actionId)),
|
|
745
763
|
},
|
|
746
764
|
};
|
|
747
765
|
const summary = buildCaseResponseLifecycleSummary(scopedRecord);
|
|
766
|
+
const latestReview = scopedRecord.observedEffects.reviews.find(review => review.id === summary.latestObservedEffect?.reviewId);
|
|
748
767
|
const providerOutcomeState = lineageComplete ? summary.providerOutcomeState : 'ambiguous';
|
|
749
768
|
return {
|
|
750
769
|
providerOutcomeState,
|
|
751
770
|
latestProviderOutcome: lineageComplete && summary.latestProviderOutcome ? { ...summary.latestProviderOutcome } : null,
|
|
752
771
|
observedChangeState: summary.observedChangeState,
|
|
753
|
-
latestObservedEffect: summary.latestObservedEffect ? { ...summary.latestObservedEffect
|
|
772
|
+
latestObservedEffect: summary.latestObservedEffect ? { ...summary.latestObservedEffect,
|
|
773
|
+
...(latestReview?.responseObject ? { responseObject: latestReview.responseObject } : {}),
|
|
774
|
+
...(latestReview?.objectOutcome ? { objectOutcome: latestReview.objectOutcome } : {}),
|
|
775
|
+
} : null,
|
|
754
776
|
latestObservedChangeAt: summary.latestObservedChangeAt,
|
|
755
777
|
closure: summary.latestClosure ? {
|
|
756
778
|
id: summary.latestClosure.id,
|
|
@@ -762,6 +784,8 @@ function normalizeResponseLifecycle(caseRecord, actionIds, lineageComplete) {
|
|
|
762
784
|
'Provider workflow outcomes and independently observed technical effects are separate point-in-time records.',
|
|
763
785
|
'A provider acknowledgement, terminal state, or reported resolution never becomes independently observed remediation, absence, or safety.',
|
|
764
786
|
'Times are withheld when the corresponding typed event is missing or ambiguous.',
|
|
787
|
+
...(objects.length ? ['Each explicit object remains independent; a latest review does not adjudicate every object bound to the action.'] : []),
|
|
788
|
+
...(caseRecord.observedEffects.reviews.length > scopedRecord.observedEffects.reviews.length ? ['Independent reviews without matching explicit action-object scope were excluded from this packet; their retained Case history is unchanged.'] : []),
|
|
765
789
|
...(!summary.latestObservedEffect && scopedRecord.observedEffects.reviews.length ? ['A single latest independent review cannot be selected from the retained observation times; all retained reviews remain separately attributed.'] : []),
|
|
766
790
|
...(!lineageComplete ? ['Provider-outcome time is withheld because the selected action origin lineage is incomplete.'] : []),
|
|
767
791
|
],
|
|
@@ -797,6 +821,7 @@ export function buildCaseResponseReviewInputs(caseRecord, input, generatedAt) {
|
|
|
797
821
|
return {
|
|
798
822
|
contract: CASE_RESPONSE_REVIEW_INPUTS_SCHEMA,
|
|
799
823
|
version: CASE_RESPONSE_REVIEW_INPUTS_VERSION,
|
|
824
|
+
sourceQualifications: caseSelectedEvidenceSourceLimitations(caseRecord.evidencePins, caseRecord.evidenceLinks, selectedEvidence.map(pin => pin.id)),
|
|
800
825
|
profile: {
|
|
801
826
|
id: profile.id,
|
|
802
827
|
label: profile.label,
|
|
@@ -936,6 +961,9 @@ export async function buildCaseResponsePacket(caseRecord, input, generatedAt = n
|
|
|
936
961
|
? 'This packet is bound to explicit confirmations for the exact reviewed-input digest. It still requires deliberate manual use.'
|
|
937
962
|
: 'This packet is a local draft with cautions and is not authorised for external use by WHOISleuth.',
|
|
938
963
|
'WHOISleuth did not submit this packet or verify that any listed contact is monitored.',
|
|
964
|
+
...reviewMaterial.sourceQualifications,
|
|
965
|
+
...(selectedPacketAction(caseRecord, input)?.correction ? [CASE_DELIVERY_RECEIPT_LIMITATION,
|
|
966
|
+
'This is a new correction or retraction request. It does not undo the original delivery, establish recipient acceptance or restoration, or replace independent recheck.'] : []),
|
|
939
967
|
...(!contacts.length ? ['No escalation contact was included.'] : []),
|
|
940
968
|
...(age.refreshRecommended ? ['The selected observation is over seven days old or appears to be in the future. Refresh evidence before submission.'] : []),
|
|
941
969
|
];
|