@slicedearth/whoisleuth-cli 2.4.0 → 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/NOTICE +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +67 -9
- package/cli/artifact-validation/lookup-evidence.mjs +18 -4
- package/cli/artifact-validation/signed-review.mjs +61 -64
- package/cli/artifact-verify.mjs +11 -2
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +1 -1
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +6 -8
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +99 -6
- package/cli/case-pack.mjs +19 -4
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -210
- package/cli/command-definition.mjs +330 -0
- package/cli/command-reference.mjs +55 -1388
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/config-profile.mjs +1 -1
- package/cli/ct-event-intake.mjs +4 -4
- package/cli/discover.mjs +2 -2
- package/cli/discovery-observation-snapshot.mjs +2 -2
- package/cli/discovery-scan-command-runner.mjs +7 -9
- package/cli/discovery-scan.mjs +1 -1
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +4 -4
- package/cli/domain-control-monitor.mjs +6 -6
- package/cli/domain-control-observations.mjs +2 -1
- package/cli/domain-feed.mjs +61 -0
- package/cli/errors.mjs +5 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +2 -3
- package/cli/evidence-signing.mjs +3 -2
- package/cli/external-observation-mapping.mjs +2 -2
- package/cli/formatters/json.mjs +1 -13
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +6 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +32 -0
- package/cli/inline-command-families.mjs +6 -47
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +147 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +3 -4
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/investigation-plan.mjs +32 -154
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +78 -12
- package/cli/local-mmdb-review.mjs +220 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +19 -35
- package/cli/lookup-diff.mjs +10 -9
- package/cli/lookup-plan.mjs +10 -14
- package/cli/lookup-reconcile.mjs +6 -5
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +9 -7
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +43 -31
- package/cli/offline-evidence-review.mjs +85 -6
- package/cli/posture.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +8 -7
- package/cli/review-arguments.mjs +132 -0
- package/cli/review-command-definitions.mjs +279 -0
- package/cli/review-command-runner.mjs +72 -159
- package/cli/risk-calibration.mjs +14 -12
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +73 -0
- package/cli/sharing-review.mjs +5 -64
- package/cli/source-reliability.mjs +2 -2
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +4 -4
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +100 -0
- package/cli/workflow-command-definitions.mjs +103 -0
- package/cli/workflow-command-runner.mjs +40 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +322 -4
- package/docs/contextual-reviews.md +178 -0
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +13 -789
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-json.mjs +2 -330
- package/lib/browser-library-profile.mjs +62 -84
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +83 -47
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +35 -15
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +27 -11
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +12 -13
- package/lib/domain-change-review.mjs +32 -14
- package/lib/domain-control-flight-recorder.mjs +103 -27
- package/lib/domain-control-manifest.mjs +25 -17
- package/lib/domain-posture-analysis.mjs +75 -7
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +45 -25
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +27 -17
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +5 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/favicon.mjs +13 -4
- package/lib/generated/cisa-kev-catalog.mjs +41 -3
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +332 -19
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +13 -2
- package/lib/http-evidence-bounds.mjs +2 -8
- package/lib/http-intelligence.mjs +13 -1
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +1 -23
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +50 -1
- package/lib/lookup-response-contract.mjs +70 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/operation-budget.mjs +4 -0
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/page-fingerprints.mjs +18 -1
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +36 -10
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-client.mjs +11 -7
- package/lib/registrable-domain.mjs +2 -56
- package/lib/registrar-standing-contract.mjs +1 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +2 -2
- package/lib/registry-capability-catalogue.mjs +27 -19
- package/lib/risk-calibration-summary.mjs +2 -352
- package/lib/risk-scoring.mjs +2 -345
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +46 -2
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +142 -139
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threat-intelligence-runtime.mjs +1 -1
- package/lib/threat-intelligence-targets.mjs +1 -1
- package/lib/threat-intelligence-types.mjs +2 -75
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +404 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +15 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +348 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +151 -0
- package/packages/cases/case-evidence-model.mjs +89 -12
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +7 -5
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +40 -24
- 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 +124 -36
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +1 -1
- package/packages/cases/case-record-operations.mjs +111 -118
- package/packages/cases/case-record-projection.mjs +23 -4
- package/packages/cases/case-record-version-input.mjs +41 -0
- package/packages/cases/case-report.mjs +93 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +159 -1
- package/packages/cases/case-response-model.mjs +16 -1
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +108 -20
- package/packages/cases/case-response-packet-render.mjs +35 -12
- package/packages/cases/case-response-packet.mjs +55 -13
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +60 -8
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +217 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- package/packages/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +1 -1
- package/packages/comparison/relationship-evidence.mjs +21 -9
- package/packages/comparison/relationship-provenance.mjs +7 -1
- package/packages/contracts/analyst-interchange.mjs +35 -15
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +360 -49
- package/packages/contracts/cli-command-semantics.mjs +5 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +25 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/domain-control-monitor.mjs +13 -2
- package/packages/contracts/domain-control-review.mjs +36 -15
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-run.mjs +2 -0
- package/packages/contracts/ip-address.mjs +10 -0
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +37 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +144 -28
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/lookup-target.mjs +5 -1
- package/packages/evidence/observation.mjs +2 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +36 -29
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +36 -0
- package/packages/interchange/managed-indicator-set.mjs +223 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +79 -1
- package/packages/investigation/bulk-mail-exposure.mjs +7 -13
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +87 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +132 -0
- package/packages/investigation/domain-transition-review.mjs +49 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -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 +20 -0
- package/packages/investigation/investigation-manifest.mjs +35 -9
- package/packages/investigation/link-intake.mjs +209 -0
- package/packages/investigation/lookup-asset-graph.mjs +1 -1
- package/packages/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +235 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- 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 +48 -4
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +27 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +11 -3
- package/packages/workspace/hostile-input.mjs +1 -1
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +26 -2
- package/packages/workspace/page-baseline.mjs +2 -2
- package/packages/workspace/relationship-observation-model.mjs +17 -13
- package/packages/workspace/shortlist-model.mjs +24 -7
- package/packages/workspace/watchlist-history.mjs +59 -16
- package/packages/workspace/watchlist-store.mjs +152 -11
- package/packages/workspace/website-snapshot-model.mjs +28 -3
- package/packages/workspace/workspace-archive-crypto.mjs +1 -1
- package/packages/workspace/workspace-archive.mjs +5 -4
- package/third-party-notices.txt +549 -16
- package/lib/hostname.mjs +0 -2
|
@@ -1,9 +1,78 @@
|
|
|
1
1
|
// Append-only response actions: transitions, reconciliation and bounded history.
|
|
2
|
+
import { readCaseEvidenceRequest, readCasePacketAmendment, assertEvidenceRequestEvent, assertEvidenceRequestHistory, assertEvidenceRequestTransition, assertPacketAmendment } from "./case-requested-evidence.mjs";
|
|
3
|
+
import { readCaseDeliveryPacketReceipt, readCasePacketCorrection, assertDeliveryPacketReceipt, correctionDelivery } from "./case-packet-correction.mjs";
|
|
4
|
+
import { MAX_INTAKE_LINKS, MAX_MESSAGE_PARTS } from "../contracts/message-intake.mjs";
|
|
5
|
+
import { emailRecipient } from "../evidence/email-recipient.mjs";
|
|
6
|
+
import { responseRouteFreshness } from "./response-route-freshness.mjs";
|
|
7
|
+
import { readCaseResponseObjects, readCaseResponseObjectOutcome, assertCaseObjectOutcome, sameCaseResponseObject } from "./case-response-object.mjs";
|
|
2
8
|
import { CASE_SCHEMA_VERSION, MAX_CASE_ACTIONS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, } from "../contracts/case-portability.mjs";
|
|
3
9
|
import { normalizeExplicitIsoTimestamp, } from "../evidence/observation.mjs";
|
|
4
10
|
import { CASE_ACTION_EVENT_SOURCE_CLASSES, CASE_ACTION_STATES, CASE_ACTION_TYPES, CASE_PROVIDER_OUTCOMES, } from "./case-response-records.mjs";
|
|
5
11
|
import { SAFE_ID_RE, boundedCounter, compareCodeUnits, deterministicId, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
|
|
6
12
|
const ACTION_TYPES = new Set(CASE_ACTION_TYPES);
|
|
13
|
+
/** Advisory page-memory comparison; neither authenticated identity nor a mutation. */
|
|
14
|
+
export function compareCaseIncomingNotice(action, report, input) {
|
|
15
|
+
const bounded = (value, maximum) => {
|
|
16
|
+
if (value === undefined)
|
|
17
|
+
return '';
|
|
18
|
+
if (value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value))
|
|
19
|
+
throw new TypeError('Notice comparison fields must be bounded single-line values.');
|
|
20
|
+
return value.trim();
|
|
21
|
+
};
|
|
22
|
+
const reference = bounded(input.reference, MAX_RESPONSE_REFERENCE_LENGTH);
|
|
23
|
+
const organisation = bounded(input.claimedOrganisation, MAX_RESPONSE_LABEL_LENGTH);
|
|
24
|
+
const recipient = emailRecipient(action?.recipient);
|
|
25
|
+
let recipientHostname = recipient ? recipient.slice(recipient.lastIndexOf('@') + 1).toLowerCase() : null;
|
|
26
|
+
if (!recipientHostname && action) {
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(action.recipient);
|
|
29
|
+
if (['https:', 'http:'].includes(url.protocol) && !url.username && !url.password)
|
|
30
|
+
recipientHostname = url.hostname;
|
|
31
|
+
}
|
|
32
|
+
catch { /* A free-text recipient does not establish a route hostname. */ }
|
|
33
|
+
}
|
|
34
|
+
const comparisons = [];
|
|
35
|
+
let senderDomains = 0, omittedSenderDomains = 0;
|
|
36
|
+
for (const identity of report.identities) {
|
|
37
|
+
if (identity.role !== 'from' && identity.role !== 'reply_to')
|
|
38
|
+
continue;
|
|
39
|
+
if (senderDomains >= MAX_MESSAGE_PARTS * 5) {
|
|
40
|
+
omittedSenderDomains += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
senderDomains += 1;
|
|
44
|
+
comparisons.push({
|
|
45
|
+
label: `Part ${identity.part} ${identity.role.replaceAll('_', ' ')} domain: ${identity.domain}`,
|
|
46
|
+
state: recipientHostname ? identity.domain.toLowerCase() === recipientHostname ? 'match' : 'mismatch' : 'unknown',
|
|
47
|
+
explanation: 'Compared only with the selected action recipient hostname. Domain agreement does not authenticate the sender; different domains may require independent clarification.',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (!senderDomains)
|
|
51
|
+
comparisons.push({ label: 'Sender domains', state: 'unknown', explanation: 'No From or Reply-To domain was retained. Exact sender addresses are not retained.' });
|
|
52
|
+
if (omittedSenderDomains)
|
|
53
|
+
comparisons.push({ label: 'Additional sender context', state: 'unknown', explanation: `${omittedSenderDomains} additional From or Reply-To claims exceed the local comparison cap. No overall sender conclusion is available.` });
|
|
54
|
+
if (!report.links.length)
|
|
55
|
+
comparisons.push({ label: 'Supplied destinations', state: 'unknown', explanation: 'No supported destination was retained. Missing or partial extraction is not proof that no destination exists.' });
|
|
56
|
+
for (const link of report.links.slice(0, MAX_INTAKE_LINKS))
|
|
57
|
+
comparisons.push({
|
|
58
|
+
label: `Destination hostname: ${link.hostname}`,
|
|
59
|
+
state: recipientHostname ? link.hostname.toLowerCase() === recipientHostname ? 'match' : 'mismatch' : 'unknown',
|
|
60
|
+
explanation: 'Hostname context only, not an exact route match or authority check. Supplied destinations have not been opened.',
|
|
61
|
+
});
|
|
62
|
+
const deliveryReferences = action?.history.filter(event => event.applied && event.previousState === 'authorised'
|
|
63
|
+
&& event.nextState === 'submitted' && event.sourceClass === 'analyst' && event.reference).map(event => event.reference) ?? [];
|
|
64
|
+
comparisons.push({ label: 'Delivery reference', state: !reference || !deliveryReferences.length ? 'unknown'
|
|
65
|
+
: deliveryReferences.includes(reference) ? 'match' : action.historyOmitted > 0 ? 'unknown' : 'mismatch',
|
|
66
|
+
explanation: 'Compared with retained, applied submission events only. Acknowledgements and later updates have separate references; missing submission history remains unknown. Matching text does not authenticate a notice.' });
|
|
67
|
+
comparisons.push({ label: 'Latest action reference', state: reference && action?.reference ? reference === action.reference ? 'match' : 'mismatch' : 'unknown',
|
|
68
|
+
explanation: 'Compared with the latest retained reference from any applied action event. This may be an acknowledgement or later update, not the delivery reference.' });
|
|
69
|
+
comparisons.push({ label: 'Claimed organisation', state: 'unknown', explanation: organisation ? 'The entered organisation is an analyst-supplied claim. Retained contact-source text is not a structured authenticated organisation identity.' : 'No organisation claim was supplied; no authenticated organisation identity is inferred.' });
|
|
70
|
+
return {
|
|
71
|
+
comparisons,
|
|
72
|
+
routeFreshness: action ? responseRouteFreshness(action.routeObservedAt, action.routeReviewAfter, input.now) : 'unknown',
|
|
73
|
+
confirmation: input.confirmedOutOfBand ? 'analyst_reported_out_of_band' : 'not_reported',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
7
76
|
const ACTION_STATES = new Set(CASE_ACTION_STATES);
|
|
8
77
|
const PROVIDER_OUTCOMES = new Set(CASE_PROVIDER_OUTCOMES);
|
|
9
78
|
const ACTION_EVENT_SOURCE_CLASSES = new Set(CASE_ACTION_EVENT_SOURCE_CLASSES);
|
|
@@ -29,6 +98,20 @@ const ACTION_TRANSITIONS = Object.freeze({
|
|
|
29
98
|
acknowledged: new Set(['acknowledged', 'terminal']),
|
|
30
99
|
terminal: new Set(),
|
|
31
100
|
});
|
|
101
|
+
// Terminal also includes deliberate withdrawal before review. This is stage
|
|
102
|
+
// completion, not proof of authorisation or submission.
|
|
103
|
+
const RESPONSE_DECISION_COMPLETE = Object.freeze({
|
|
104
|
+
drafting: false,
|
|
105
|
+
ready_for_review: false,
|
|
106
|
+
reviewed: true,
|
|
107
|
+
authorised: true,
|
|
108
|
+
submitted: true,
|
|
109
|
+
acknowledged: true,
|
|
110
|
+
terminal: true,
|
|
111
|
+
});
|
|
112
|
+
export function caseActionCompletesResponseDecision(state) {
|
|
113
|
+
return RESPONSE_DECISION_COMPLETE[state];
|
|
114
|
+
}
|
|
32
115
|
function bytes(value) {
|
|
33
116
|
try {
|
|
34
117
|
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
@@ -76,6 +159,16 @@ function normalizeActionEvent(raw, actionId, options = {}) {
|
|
|
76
159
|
&& sourceClass !== 'analyst' && !migrationSnapshot)
|
|
77
160
|
return null;
|
|
78
161
|
const provenance = text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || `${sourceClass}_record`;
|
|
162
|
+
if (item.evidenceRequest !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
|
|
163
|
+
throw new Error('Requested evidence requires Case schema 17.');
|
|
164
|
+
const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
|
|
165
|
+
if (item.packetReceipt !== undefined && options.sourceVersion != null && options.sourceVersion < 19)
|
|
166
|
+
throw new TypeError('Delivery receipts require Case schema 19.');
|
|
167
|
+
const packetReceipt = readCaseDeliveryPacketReceipt(item.packetReceipt);
|
|
168
|
+
assertDeliveryPacketReceipt(packetReceipt, { previousState, nextState, sourceClass, occurredAt,
|
|
169
|
+
reference: typeof item.reference === 'string' ? item.reference : null,
|
|
170
|
+
responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }, actionId);
|
|
171
|
+
assertEvidenceRequestEvent(evidenceRequest, { previousState, nextState, sourceClass, providerOutcome: item.providerOutcome });
|
|
79
172
|
const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
|
|
80
173
|
? item.providerOutcome
|
|
81
174
|
: null;
|
|
@@ -104,6 +197,10 @@ function normalizeActionEvent(raw, actionId, options = {}) {
|
|
|
104
197
|
: null;
|
|
105
198
|
const eventMaterial = {
|
|
106
199
|
previousState,
|
|
200
|
+
...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }),
|
|
201
|
+
...(item.objectOutcome === undefined ? {} : { objectOutcome: readCaseResponseObjectOutcome(item.objectOutcome, options.sourceVersion) }),
|
|
202
|
+
...(evidenceRequest ? { evidenceRequest } : {}),
|
|
203
|
+
...(packetReceipt ? { packetReceipt } : {}),
|
|
107
204
|
nextState,
|
|
108
205
|
occurredAt,
|
|
109
206
|
sourceClass,
|
|
@@ -120,6 +217,16 @@ function normalizeActionEvent(raw, actionId, options = {}) {
|
|
|
120
217
|
? item.originActionId
|
|
121
218
|
: null,
|
|
122
219
|
};
|
|
220
|
+
if (eventMaterial.objectOutcome) {
|
|
221
|
+
if (!['submitted', 'acknowledged', 'terminal'].includes(nextState) || previousState === 'authorised')
|
|
222
|
+
throw new TypeError('Object outcomes require a separate attributed event after submission.');
|
|
223
|
+
if (sourceClass !== 'provider' && sourceClass !== 'analyst')
|
|
224
|
+
throw new TypeError('Object outcomes require an attributed provider or analyst event.');
|
|
225
|
+
if (!eventMaterial.responseObjects?.length)
|
|
226
|
+
throw new TypeError('Select the objects concerned by this provider outcome; no complete-action resolution is inferred.');
|
|
227
|
+
for (const object of eventMaterial.responseObjects)
|
|
228
|
+
assertCaseObjectOutcome(eventMaterial.objectOutcome, object);
|
|
229
|
+
}
|
|
123
230
|
return {
|
|
124
231
|
id: safeId(item.id, 'action-event', { actionId, ...eventMaterial }),
|
|
125
232
|
...eventMaterial,
|
|
@@ -283,6 +390,15 @@ function normalizeAction(raw, fallback, options = {}) {
|
|
|
283
390
|
const createdAt = iso(item.createdAt, fallback, options);
|
|
284
391
|
const actionId = safeId(item.id, 'action', { recipient, createdAt });
|
|
285
392
|
const history = normalizeActionHistory(item.history, item, actionId, createdAt, fallback, options);
|
|
393
|
+
assertEvidenceRequestHistory(history.history, history.omitted > 0);
|
|
394
|
+
if (item.amendment !== undefined && options.sourceVersion != null && options.sourceVersion < 17)
|
|
395
|
+
throw new Error('Packet amendments require Case schema 17.');
|
|
396
|
+
const amendment = readCasePacketAmendment(item.amendment);
|
|
397
|
+
if (item.correction !== undefined && options.sourceVersion != null && options.sourceVersion < 19)
|
|
398
|
+
throw new TypeError('Packet corrections require Case schema 19.');
|
|
399
|
+
const correction = readCasePacketCorrection(item.correction);
|
|
400
|
+
if (correction && amendment)
|
|
401
|
+
throw new TypeError('Self-initiated correction and provider-request amendment are separate purposes.');
|
|
286
402
|
const applied = history.history.filter((event) => event.applied);
|
|
287
403
|
const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
|
|
288
404
|
const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
|
|
@@ -297,6 +413,9 @@ function normalizeAction(raw, fallback, options = {}) {
|
|
|
297
413
|
: null;
|
|
298
414
|
return {
|
|
299
415
|
id: actionId,
|
|
416
|
+
...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects, options.sourceVersion) }),
|
|
417
|
+
...(amendment ? { amendment } : {}),
|
|
418
|
+
...(correction ? { correction } : {}),
|
|
300
419
|
type: legacyPlatformReview
|
|
301
420
|
? 'platform_report'
|
|
302
421
|
: typeof item.type === 'string' && ACTION_TYPES.has(item.type)
|
|
@@ -481,9 +600,17 @@ export function appendCaseAction(current, raw, now) {
|
|
|
481
600
|
: null;
|
|
482
601
|
if (item.originActionId != null && !originActionId)
|
|
483
602
|
throw new Error('A follow-on action requires an existing originating action.');
|
|
603
|
+
const amendment = readCasePacketAmendment(item.amendment);
|
|
604
|
+
if (amendment)
|
|
605
|
+
assertPacketAmendment(current, originActionId, amendment);
|
|
606
|
+
const correction = readCasePacketCorrection(item.correction);
|
|
607
|
+
if (correction)
|
|
608
|
+
correctionDelivery(current, { originActionId, correction,
|
|
609
|
+
recipient: text(item.recipient, MAX_RESPONSE_RECIPIENT_LENGTH), responseObjects: readCaseResponseObjects(item.responseObjects) });
|
|
484
610
|
const history = [{
|
|
485
611
|
id: freshId('action-event'),
|
|
486
612
|
previousState: null,
|
|
613
|
+
...(item.responseObjects === undefined ? {} : { responseObjects: readCaseResponseObjects(item.responseObjects) }),
|
|
487
614
|
nextState: 'drafting',
|
|
488
615
|
occurredAt: now,
|
|
489
616
|
sourceClass: 'analyst',
|
|
@@ -508,11 +635,22 @@ export function appendCaseAction(current, raw, now) {
|
|
|
508
635
|
throw new Error('An action requires a recipient or internal owner.');
|
|
509
636
|
return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
510
637
|
}
|
|
638
|
+
export function providerOutcomeRequiresObjectScope(outcome) {
|
|
639
|
+
return outcome === 'provider_reports_resolved' || outcome === 'partially_remediated';
|
|
640
|
+
}
|
|
511
641
|
export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
|
|
512
642
|
const action = current.find((item) => item.id === actionId);
|
|
513
643
|
if (!action)
|
|
514
644
|
throw new Error('That case action no longer exists.');
|
|
515
645
|
const item = record(raw);
|
|
646
|
+
const selectedObjects = readCaseResponseObjects(item.responseObjects);
|
|
647
|
+
const outcome = readCaseResponseObjectOutcome(item.objectOutcome);
|
|
648
|
+
if (selectedObjects?.some(object => !action.responseObjects?.some(bound => sameCaseResponseObject(bound, object))))
|
|
649
|
+
throw new TypeError('A transition may concern only objects explicitly bound to its action.');
|
|
650
|
+
if (outcome && !selectedObjects?.length)
|
|
651
|
+
throw new TypeError('Explicitly select the affected objects for this outcome; it is not applied to every action object.');
|
|
652
|
+
if (action.responseObjects?.length && providerOutcomeRequiresObjectScope(item.providerOutcome) && !selectedObjects?.length)
|
|
653
|
+
throw new TypeError('Explicitly select the objects covered by this provider-reported outcome; other action objects remain unchecked.');
|
|
516
654
|
const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
|
|
517
655
|
? item.nextState
|
|
518
656
|
: null;
|
|
@@ -537,9 +675,24 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
|
|
|
537
675
|
|| !SAFE_ID_RE.test(item.evidencePinId) || (validPinIds && !validPinIds.has(item.evidencePinId)))) {
|
|
538
676
|
throw new Error('An action transition evidence pin must reference a retained Case evidence pin.');
|
|
539
677
|
}
|
|
678
|
+
const evidenceRequest = readCaseEvidenceRequest(item.evidenceRequest);
|
|
679
|
+
if (evidenceRequest)
|
|
680
|
+
assertEvidenceRequestTransition(action, evidenceRequest, sourceClass, item.providerOutcome, validPinIds);
|
|
681
|
+
if (action.amendment && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)) {
|
|
682
|
+
assertPacketAmendment(current, action.originActionId, action.amendment);
|
|
683
|
+
}
|
|
684
|
+
if (action.correction && ['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState))
|
|
685
|
+
correctionDelivery(current, action);
|
|
686
|
+
const packetReceipt = readCaseDeliveryPacketReceipt(item.packetReceipt);
|
|
687
|
+
if (packetReceipt && (packetReceipt.recipient !== action.recipient
|
|
688
|
+
|| JSON.stringify(packetReceipt.responseObjects) !== JSON.stringify(action.responseObjects ?? [])))
|
|
689
|
+
throw new TypeError('The delivered packet receipt no longer matches this action recipient or scope.');
|
|
690
|
+
if (action.correction && nextState === 'submitted' && !packetReceipt)
|
|
691
|
+
throw new TypeError('Record the separately prepared correction packet receipt only after actual delivery.');
|
|
540
692
|
const occurredAt = optionalIso(item.occurredAt) ?? now;
|
|
541
693
|
const event = normalizeActionEvent({
|
|
542
694
|
...item,
|
|
695
|
+
...(selectedObjects !== undefined ? { responseObjects: selectedObjects } : action.responseObjects === undefined ? {} : { responseObjects: action.responseObjects }),
|
|
543
696
|
id: freshId('action-event'),
|
|
544
697
|
previousState: action.state,
|
|
545
698
|
nextState,
|
|
@@ -558,7 +711,7 @@ export function appendCaseActionTransition(current, actionId, raw, now, validPin
|
|
|
558
711
|
return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
|
|
559
712
|
}
|
|
560
713
|
const ACTION_REVIEW_MATERIAL_FIELDS = [
|
|
561
|
-
'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId',
|
|
714
|
+
'type', 'recipient', 'contactSource', 'routeObservedAt', 'routeReviewAfter', 'contactLimitations', 'originActionId', 'amendment', 'correction', 'responseObjects',
|
|
562
715
|
];
|
|
563
716
|
export function updateCaseAction(current, raw, now, validPinIds) {
|
|
564
717
|
const patch = record(raw);
|
|
@@ -587,6 +740,10 @@ export function updateCaseAction(current, raw, now, validPinIds) {
|
|
|
587
740
|
throw new Error('An action requires a recipient or internal owner.');
|
|
588
741
|
const materialChanged = ACTION_REVIEW_MATERIAL_FIELDS
|
|
589
742
|
.some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
|
|
743
|
+
if (materialChanged && updated.amendment)
|
|
744
|
+
assertPacketAmendment(current, updated.originActionId, updated.amendment);
|
|
745
|
+
if (materialChanged && updated.correction)
|
|
746
|
+
correctionDelivery(current, updated);
|
|
590
747
|
if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
|
|
591
748
|
throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
|
|
592
749
|
}
|
|
@@ -600,6 +757,7 @@ export function updateCaseAction(current, raw, now, validPinIds) {
|
|
|
600
757
|
occurredAt: now,
|
|
601
758
|
sourceClass: 'browser_local',
|
|
602
759
|
provenance: 'material_action_change',
|
|
760
|
+
...(updated.responseObjects === undefined ? {} : { responseObjects: updated.responseObjects }),
|
|
603
761
|
reference: null,
|
|
604
762
|
evidencePinId: null,
|
|
605
763
|
limitations: ['Material action inputs changed after review; prior readiness, review, or authorisation no longer applies.'],
|
|
@@ -5,11 +5,14 @@
|
|
|
5
5
|
import { CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, } from "../contracts/case-portability.mjs";
|
|
6
6
|
import { CASE_ASSERTION_EXTERNAL_ENTITY_TYPES, CASE_ASSERTION_EXTERNAL_FORMATS, CASE_ASSERTION_KINDS, CASE_ASSERTION_STATES, CASE_DECISION_CONFIDENCE_LEVELS, CASE_EVIDENCE_RELATION_STANCES, CASE_MANUAL_TRAIL_KINDS, CASE_SIGHTING_CATEGORIES, CASE_SIGHTING_STATES, CASE_TRANSITION_EXPECTATIONS, } from "./case-response-records.mjs";
|
|
7
7
|
import { COMPLETENESS, SAFE_ID_RE, compareCodeUnits, freshId, iso, limitations, optionalIso, record, safeId, text, uniqueIds, } from "./case-response-values.mjs";
|
|
8
|
-
import { isValidAsciiHostname } from "
|
|
8
|
+
import { isValidAsciiHostname } from "../contracts/domain-name.mjs";
|
|
9
9
|
import { readCaseRecheckContext } from "./case-recheck-model.mjs";
|
|
10
|
+
import { readCaseResponseObject } from "./case-response-object.mjs";
|
|
11
|
+
import { normalizeInfrastructureObservation } from "../investigation/infrastructure-observation.mjs";
|
|
10
12
|
export * from "./case-response-records.mjs";
|
|
11
13
|
export * from "./case-response-actions.mjs";
|
|
12
14
|
export * from "./case-response-outcomes.mjs";
|
|
15
|
+
export * from "./case-response-object.mjs";
|
|
13
16
|
export { MAX_ASSERTION_PROVENANCE_LABELS, MAX_ASSERTION_PROVENANCE_MARKINGS, MAX_CASE_ACTION_BYTES, MAX_CASE_ACTION_EVENTS_PER_ACTION, MAX_CASE_ACTION_EVENTS_PER_CASE, MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE, MAX_CASE_ACTIONS, MAX_CASE_ASSERTIONS, MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_CLOSURES, MAX_CASE_DECISIONS, MAX_CASE_EVIDENCE_PINS, MAX_CASE_MANUAL_TRAIL_EVENTS, MAX_CASE_OBSERVED_EFFECT_REVIEWS, MAX_CASE_SIGHTINGS, MAX_DECISION_PIN_REFERENCES, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_RECIPIENT_LENGTH, MAX_RESPONSE_REFERENCE_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_TRAIL_TARGET_LENGTH, } from "../contracts/case-portability.mjs";
|
|
14
17
|
const TRANSITION_EXPECTATIONS = new Set(CASE_TRANSITION_EXPECTATIONS);
|
|
15
18
|
const DECISION_CONFIDENCE_LEVELS = new Set(CASE_DECISION_CONFIDENCE_LEVELS);
|
|
@@ -108,6 +111,8 @@ function normalizeEvidenceRelations(value, legacyIds, validIds) {
|
|
|
108
111
|
}
|
|
109
112
|
function normalizePin(raw, fallback, options = {}) {
|
|
110
113
|
const item = record(raw);
|
|
114
|
+
if (item.infrastructureObservation !== undefined && options.sourceVersion != null && options.sourceVersion < 18)
|
|
115
|
+
throw new TypeError('Infrastructure observations require Case schema 18 or later.');
|
|
111
116
|
const label = text(item.label, MAX_RESPONSE_LABEL_LENGTH);
|
|
112
117
|
const value = text(item.value, MAX_RESPONSE_VALUE_LENGTH);
|
|
113
118
|
if (!label || !value)
|
|
@@ -141,6 +146,8 @@ function normalizePin(raw, fallback, options = {}) {
|
|
|
141
146
|
: null,
|
|
142
147
|
limitations: limitations(item.limitations),
|
|
143
148
|
createdAt,
|
|
149
|
+
...(item.responseObject === undefined ? {} : { responseObject: readCaseResponseObject(item.responseObject, options.sourceVersion) }),
|
|
150
|
+
...(item.infrastructureObservation === undefined ? {} : { infrastructureObservation: normalizeInfrastructureObservation(item.infrastructureObservation) }),
|
|
144
151
|
};
|
|
145
152
|
normalized.certificateObservation = options.allowCertificateObservation === false
|
|
146
153
|
? null
|
|
@@ -195,6 +202,8 @@ export function appendCaseEvidencePin(current, raw, now) {
|
|
|
195
202
|
export function appendCaseEvidencePins(current, raw, now) {
|
|
196
203
|
if (!Array.isArray(raw) || !raw.length)
|
|
197
204
|
throw new Error('An evidence checkpoint requires at least one selected fact.');
|
|
205
|
+
if (raw.length > MAX_CASE_CHECKPOINT_FACTS)
|
|
206
|
+
throw new Error(`A checkpoint can retain at most ${MAX_CASE_CHECKPOINT_FACTS} selected facts. No evidence was saved.`);
|
|
198
207
|
let output = [...current];
|
|
199
208
|
let added = 0;
|
|
200
209
|
for (const item of raw.slice(0, MAX_CASE_CHECKPOINT_FACTS)) {
|
|
@@ -240,6 +249,12 @@ export function normalizeCaseDecisions(raw, fallback, validPinIds, options = {})
|
|
|
240
249
|
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt))
|
|
241
250
|
.slice(-MAX_CASE_DECISIONS);
|
|
242
251
|
}
|
|
252
|
+
/** Counts decisions, not links; references to pins no longer retained do not
|
|
253
|
+
* support a decision. Inputs have already passed their owning normalisers. */
|
|
254
|
+
export function countEvidenceLinkedCaseDecisions(decisions, pins) {
|
|
255
|
+
const retainedPinIds = new Set(pins.map(pin => pin.id));
|
|
256
|
+
return decisions.filter(decision => decision.evidencePinIds.some(id => retainedPinIds.has(id))).length;
|
|
257
|
+
}
|
|
243
258
|
export function appendCaseDecision(current, raw, now, validPinIds) {
|
|
244
259
|
if (current.length >= MAX_CASE_DECISIONS) {
|
|
245
260
|
throw new Error(`A Case can retain at most ${MAX_CASE_DECISIONS} decisions. No existing decision was removed.`);
|
|
@@ -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,9 @@
|
|
|
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";
|
|
6
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
5
7
|
import { CASE_CLOSURE_REASONS, CASE_OBSERVED_EFFECT_SOURCE_CLASSES, CASE_OBSERVED_EFFECT_STATES, } from "./case-response-records.mjs";
|
|
6
8
|
import { COMPLETENESS, SAFE_ID_RE, boundedCounter, compareCodeUnits, freshId, iso, lifecycleLimitations, limitations, optionalIso, record, safeId, text, } from "./case-response-values.mjs";
|
|
7
9
|
const OBSERVED_EFFECT_STATES = new Set(CASE_OBSERVED_EFFECT_STATES);
|
|
@@ -19,6 +21,16 @@ function normalizeObservedEffectReview(raw, fallback, validPinIds, validSighting
|
|
|
19
21
|
return null;
|
|
20
22
|
const createdAt = iso(item.createdAt, observedAt || fallback, options);
|
|
21
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.');
|
|
22
34
|
if (recheck && item.state === 'not_reproduced' && (item.completeness !== 'complete' || recheck.conditionsMatch !== 'comparable')) {
|
|
23
35
|
throw new TypeError('A question cannot be marked not reproduced from incomplete evidence or unconfirmed comparison conditions.');
|
|
24
36
|
}
|
|
@@ -50,6 +62,8 @@ function normalizeObservedEffectReview(raw, fallback, validPinIds, validSighting
|
|
|
50
62
|
sightingId,
|
|
51
63
|
followUpAt: optionalIso(item.followUpAt, options),
|
|
52
64
|
...(recheck ? { recheck } : {}),
|
|
65
|
+
...(responseObject ? { responseObject } : {}),
|
|
66
|
+
...(objectOutcome ? { objectOutcome } : {}),
|
|
53
67
|
createdAt,
|
|
54
68
|
};
|
|
55
69
|
}
|
|
@@ -127,6 +141,7 @@ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options
|
|
|
127
141
|
if (!summary)
|
|
128
142
|
return null;
|
|
129
143
|
const createdAt = iso(item.createdAt, fallback, options);
|
|
144
|
+
const responseObject = readCaseResponseObject(item.responseObject, options.sourceVersion);
|
|
130
145
|
const observedEffectReviewId = typeof item.observedEffectReviewId === 'string'
|
|
131
146
|
&& SAFE_ID_RE.test(item.observedEffectReviewId)
|
|
132
147
|
&& (!validReviewIds || validReviewIds.has(item.observedEffectReviewId))
|
|
@@ -149,7 +164,8 @@ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options
|
|
|
149
164
|
return null;
|
|
150
165
|
const linkedProviderEvents = actionId ? linkContext.providerResolutionEvents?.get(actionId) ?? [] : [];
|
|
151
166
|
if (linkContext.providerResolutionEvents && reason === 'provider_reported_resolution_not_independently_checked'
|
|
152
|
-
&& !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)))
|
|
153
169
|
return null;
|
|
154
170
|
const linkLimitations = [
|
|
155
171
|
...(item.observedEffectReviewId != null && !observedEffectReviewId ? ['A malformed or dangling observed-effect review reference was omitted from this closure.'] : []),
|
|
@@ -157,6 +173,7 @@ function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options
|
|
|
157
173
|
];
|
|
158
174
|
return {
|
|
159
175
|
id: safeId(item.id, 'case-closure', { reason: item.reason, summary, createdAt }),
|
|
176
|
+
...(responseObject === undefined ? {} : { responseObject }),
|
|
160
177
|
reason,
|
|
161
178
|
summary,
|
|
162
179
|
observedEffectReviewId,
|
|
@@ -173,8 +190,11 @@ export function buildCaseClosureLinkContext(observedEffects, actions) {
|
|
|
173
190
|
createdAt: review.createdAt,
|
|
174
191
|
}])),
|
|
175
192
|
providerResolutionEvents: new Map(actions.map((action) => [action.id, action.history
|
|
176
|
-
|
|
177
|
-
|
|
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 }) }))])),
|
|
178
198
|
};
|
|
179
199
|
}
|
|
180
200
|
export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, validActionIds, options = {}, linkContext = {}) {
|
|
@@ -222,7 +242,82 @@ export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, valid
|
|
|
222
242
|
]),
|
|
223
243
|
};
|
|
224
244
|
}
|
|
225
|
-
|
|
245
|
+
/** New-write policy only. Historical normalisation must not re-adjudicate a closure. */
|
|
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.';
|
|
249
|
+
if (reason !== 'independently_not_reproduced' && reason !== 'infrastructure_changed')
|
|
250
|
+
return null;
|
|
251
|
+
const expected = reason === 'independently_not_reproduced' ? 'not_reproduced' : 'changed';
|
|
252
|
+
const invalidLink = reason === 'independently_not_reproduced'
|
|
253
|
+
? 'This closure reason requires a linked independent not-reproduced review.'
|
|
254
|
+
: 'This closure reason requires a linked independent changed review.';
|
|
255
|
+
if (!review || review.state !== expected)
|
|
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
|
+
}
|
|
269
|
+
const observedAt = normalizeExplicitIsoTimestamp(review.observedAt);
|
|
270
|
+
const createdAt = normalizeExplicitIsoTimestamp(review.createdAt);
|
|
271
|
+
const closedAt = normalizeExplicitIsoTimestamp(now);
|
|
272
|
+
if (!observedAt || !createdAt || !closedAt
|
|
273
|
+
|| Date.parse(observedAt) > Date.parse(closedAt)
|
|
274
|
+
|| Date.parse(createdAt) > Date.parse(closedAt))
|
|
275
|
+
return invalidLink;
|
|
276
|
+
if (reason === 'independently_not_reproduced') {
|
|
277
|
+
if (review.completeness !== 'complete')
|
|
278
|
+
return 'Independent non-reproduction closure requires a complete observation. Keep this limited review and collect or record a complete recheck.';
|
|
279
|
+
if (review.recheck && review.recheck.conditionsMatch !== 'comparable')
|
|
280
|
+
return 'Independent non-reproduction closure requires comparable recheck conditions.';
|
|
281
|
+
}
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
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 = []) {
|
|
226
321
|
const item = record(raw);
|
|
227
322
|
const reason = typeof item.reason === 'string' && CLOSURE_REASONS.has(item.reason)
|
|
228
323
|
? item.reason
|
|
@@ -233,21 +328,13 @@ export function appendCaseClosure(current, raw, now, observedEffects, actions) {
|
|
|
233
328
|
const action = typeof item.actionId === 'string'
|
|
234
329
|
? actions.find((candidate) => candidate.id === item.actionId) ?? null
|
|
235
330
|
: null;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
throw new Error(
|
|
243
|
-
}
|
|
244
|
-
if (reason === 'provider_reported_resolution_not_independently_checked'
|
|
245
|
-
&& (action?.providerOutcome !== 'provider_reports_resolved'
|
|
246
|
-
|| !action.history.some((event) => event.applied
|
|
247
|
-
&& event.providerOutcome === 'provider_reports_resolved'
|
|
248
|
-
&& Date.parse(event.occurredAt) <= Date.parse(now)))) {
|
|
249
|
-
throw new Error('This closure reason requires a linked typed provider-reported-resolution outcome.');
|
|
250
|
-
}
|
|
331
|
+
const responseObject = readCaseResponseObject(item.responseObject);
|
|
332
|
+
const reviewBlocker = caseClosureReviewBlocker(reason, review, now, responseObject, evidencePins);
|
|
333
|
+
if (reviewBlocker)
|
|
334
|
+
throw new Error(reviewBlocker);
|
|
335
|
+
const actionBlocker = caseClosureActionBlocker(reason, action, responseObject, now);
|
|
336
|
+
if (actionBlocker)
|
|
337
|
+
throw new Error(actionBlocker);
|
|
251
338
|
const linkContext = buildCaseClosureLinkContext(observedEffects, actions);
|
|
252
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);
|
|
253
340
|
if (!created || !reason)
|
|
@@ -293,6 +380,7 @@ export function buildCaseResponseLifecycleSummary(input) {
|
|
|
293
380
|
? 'missing'
|
|
294
381
|
: latestObservedChangeAt ? 'available' : 'ambiguous';
|
|
295
382
|
const latestClosure = [...(input.closures?.records ?? [])]
|
|
383
|
+
.filter(closure => closure.responseObject === undefined)
|
|
296
384
|
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || compareCodeUnits(right.id, left.id))[0] ?? null;
|
|
297
385
|
return {
|
|
298
386
|
providerOutcomeState,
|