@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -2,111 +2,17 @@
|
|
|
2
2
|
// deliberately separate from collected evidence snapshots: a pin describes a
|
|
3
3
|
// fact selected by an analyst, a decision records analyst reasoning, and an
|
|
4
4
|
// action records a reviewed external or internal follow-up.
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
'network_hosting_report',
|
|
15
|
-
'security_contact_report',
|
|
16
|
-
'defensive_control',
|
|
17
|
-
'internal_review',
|
|
18
|
-
];
|
|
19
|
-
export const CASE_ACTION_STATES = [
|
|
20
|
-
'drafting',
|
|
21
|
-
'ready_for_review',
|
|
22
|
-
'reviewed',
|
|
23
|
-
'authorised',
|
|
24
|
-
'submitted',
|
|
25
|
-
'acknowledged',
|
|
26
|
-
'terminal',
|
|
27
|
-
];
|
|
28
|
-
export const CASE_PROVIDER_OUTCOMES = [
|
|
29
|
-
'accepted_for_review',
|
|
30
|
-
'more_information_requested',
|
|
31
|
-
'referred_elsewhere',
|
|
32
|
-
'rejected_outside_policy_scope',
|
|
33
|
-
'no_response',
|
|
34
|
-
'partially_remediated',
|
|
35
|
-
'provider_reports_resolved',
|
|
36
|
-
'withdrawn',
|
|
37
|
-
'duplicate',
|
|
38
|
-
];
|
|
39
|
-
export const CASE_ACTION_EVENT_SOURCE_CLASSES = [
|
|
40
|
-
'analyst',
|
|
41
|
-
'provider',
|
|
42
|
-
'browser_local',
|
|
43
|
-
'import',
|
|
44
|
-
'migration',
|
|
45
|
-
];
|
|
46
|
-
export const CASE_OBSERVED_EFFECT_STATES = [
|
|
47
|
-
'not_checked',
|
|
48
|
-
'still_observed',
|
|
49
|
-
'changed',
|
|
50
|
-
'not_reproduced',
|
|
51
|
-
'unavailable',
|
|
52
|
-
];
|
|
53
|
-
export const CASE_OBSERVED_EFFECT_SOURCE_CLASSES = ['deployment', 'analyst', 'import'];
|
|
54
|
-
export const CASE_CLOSURE_REASONS = [
|
|
55
|
-
'independently_not_reproduced',
|
|
56
|
-
'infrastructure_changed',
|
|
57
|
-
'provider_reported_resolution_not_independently_checked',
|
|
58
|
-
'risk_accepted',
|
|
59
|
-
'false_positive',
|
|
60
|
-
'duplicate',
|
|
61
|
-
'unable_to_proceed',
|
|
62
|
-
'monitoring_transferred',
|
|
63
|
-
];
|
|
64
|
-
export const CASE_ASSERTION_KINDS = [
|
|
65
|
-
'verified_fact',
|
|
66
|
-
'hypothesis',
|
|
67
|
-
'unknown',
|
|
68
|
-
'contradiction',
|
|
69
|
-
'next_step',
|
|
70
|
-
];
|
|
71
|
-
export const CASE_ASSERTION_STATES = ['open', 'resolved'];
|
|
72
|
-
export const CASE_ASSERTION_EXTERNAL_FORMATS = ['stix', 'misp'];
|
|
73
|
-
export const CASE_ASSERTION_EXTERNAL_ENTITY_TYPES = [
|
|
74
|
-
'asn',
|
|
75
|
-
'certificate',
|
|
76
|
-
'domain',
|
|
77
|
-
'hostname',
|
|
78
|
-
'ipv4',
|
|
79
|
-
'ipv6',
|
|
80
|
-
'url',
|
|
81
|
-
];
|
|
82
|
-
export const CASE_MANUAL_TRAIL_KINDS = ['pivot', 'review', 'handoff'];
|
|
83
|
-
export const CASE_SIGHTING_STATES = [
|
|
84
|
-
'observed_by_deployment',
|
|
85
|
-
'reported_by_provider',
|
|
86
|
-
'analyst_confirmed',
|
|
87
|
-
'not_reproduced',
|
|
88
|
-
'expired',
|
|
89
|
-
];
|
|
90
|
-
export const CASE_SIGHTING_CATEGORIES = [
|
|
91
|
-
'registration',
|
|
92
|
-
'delegation',
|
|
93
|
-
'certificate',
|
|
94
|
-
'mail',
|
|
95
|
-
'website',
|
|
96
|
-
'infrastructure',
|
|
97
|
-
'other',
|
|
98
|
-
];
|
|
99
|
-
const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/u;
|
|
100
|
-
const CONTROL_REPLACE_RE = /[\u0000-\u001f\u007f]+/gu;
|
|
101
|
-
const COMPLETENESS = new Set(CASE_PIN_COMPLETENESS);
|
|
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
|
+
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
|
+
import { COMPLETENESS, SAFE_ID_RE, compareCodeUnits, freshId, iso, limitations, optionalIso, record, safeId, text, uniqueIds, } from "./case-response-values.mjs";
|
|
8
|
+
import { isValidAsciiHostname } from "../../lib/hostname.mjs";
|
|
9
|
+
import { readCaseRecheckContext } from "./case-recheck-model.mjs";
|
|
10
|
+
export * from "./case-response-records.mjs";
|
|
11
|
+
export * from "./case-response-actions.mjs";
|
|
12
|
+
export * from "./case-response-outcomes.mjs";
|
|
13
|
+
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";
|
|
102
14
|
const TRANSITION_EXPECTATIONS = new Set(CASE_TRANSITION_EXPECTATIONS);
|
|
103
|
-
const
|
|
104
|
-
const ACTION_STATES = new Set(CASE_ACTION_STATES);
|
|
105
|
-
const PROVIDER_OUTCOMES = new Set(CASE_PROVIDER_OUTCOMES);
|
|
106
|
-
const ACTION_EVENT_SOURCE_CLASSES = new Set(CASE_ACTION_EVENT_SOURCE_CLASSES);
|
|
107
|
-
const OBSERVED_EFFECT_STATES = new Set(CASE_OBSERVED_EFFECT_STATES);
|
|
108
|
-
const OBSERVED_EFFECT_SOURCE_CLASSES = new Set(CASE_OBSERVED_EFFECT_SOURCE_CLASSES);
|
|
109
|
-
const CLOSURE_REASONS = new Set(CASE_CLOSURE_REASONS);
|
|
15
|
+
const DECISION_CONFIDENCE_LEVELS = new Set(CASE_DECISION_CONFIDENCE_LEVELS);
|
|
110
16
|
const ASSERTION_KINDS = new Set(CASE_ASSERTION_KINDS);
|
|
111
17
|
const ASSERTION_STATES = new Set(CASE_ASSERTION_STATES);
|
|
112
18
|
const EVIDENCE_RELATION_STANCES = new Set(CASE_EVIDENCE_RELATION_STANCES);
|
|
@@ -116,71 +22,6 @@ const TRAIL_KINDS = new Set(CASE_MANUAL_TRAIL_KINDS);
|
|
|
116
22
|
const SIGHTING_STATES = new Set(CASE_SIGHTING_STATES);
|
|
117
23
|
const SIGHTING_CATEGORIES = new Set(CASE_SIGHTING_CATEGORIES);
|
|
118
24
|
const SHA256_RE = /^[a-f0-9]{64}$/u;
|
|
119
|
-
function currentActionNormalizationOptions(validEvidencePinIds) {
|
|
120
|
-
return validEvidencePinIds
|
|
121
|
-
? { sourceVersion: CASE_SCHEMA_VERSION, validEvidencePinIds }
|
|
122
|
-
: { sourceVersion: CASE_SCHEMA_VERSION };
|
|
123
|
-
}
|
|
124
|
-
function record(value) {
|
|
125
|
-
return value && typeof value === 'object' && !Array.isArray(value)
|
|
126
|
-
? value
|
|
127
|
-
: {};
|
|
128
|
-
}
|
|
129
|
-
function text(value, maximum) {
|
|
130
|
-
if (typeof value !== 'string')
|
|
131
|
-
return '';
|
|
132
|
-
return value.replace(CONTROL_REPLACE_RE, ' ').trim().slice(0, maximum);
|
|
133
|
-
}
|
|
134
|
-
function timestamp(value, options = {}) {
|
|
135
|
-
return normalizeExplicitIsoTimestamp(value)
|
|
136
|
-
?? (options.legacyTimestamps ? normalizeLegacyIsoTimestamp(value) : null);
|
|
137
|
-
}
|
|
138
|
-
function iso(value, fallback, options = {}) {
|
|
139
|
-
return timestamp(value, options) ?? fallback;
|
|
140
|
-
}
|
|
141
|
-
function optionalIso(value, options = {}) {
|
|
142
|
-
return timestamp(value, options);
|
|
143
|
-
}
|
|
144
|
-
function hash(value) {
|
|
145
|
-
let result = 2166136261 >>> 0;
|
|
146
|
-
for (let index = 0; index < value.length; index += 1) {
|
|
147
|
-
result ^= value.charCodeAt(index);
|
|
148
|
-
result = Math.imul(result, 16777619);
|
|
149
|
-
}
|
|
150
|
-
return (result >>> 0).toString(36);
|
|
151
|
-
}
|
|
152
|
-
function deterministicId(prefix, value) {
|
|
153
|
-
return `${prefix}-${hash(JSON.stringify(value))}`;
|
|
154
|
-
}
|
|
155
|
-
function freshId(prefix) {
|
|
156
|
-
if (typeof globalThis.crypto?.randomUUID === 'function')
|
|
157
|
-
return globalThis.crypto.randomUUID();
|
|
158
|
-
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
159
|
-
}
|
|
160
|
-
function safeId(value, prefix, raw) {
|
|
161
|
-
return typeof value === 'string' && SAFE_ID_RE.test(value)
|
|
162
|
-
? value
|
|
163
|
-
: deterministicId(prefix, raw);
|
|
164
|
-
}
|
|
165
|
-
function limitations(value) {
|
|
166
|
-
if (!Array.isArray(value))
|
|
167
|
-
return [];
|
|
168
|
-
const unique = new Set();
|
|
169
|
-
for (const item of value.slice(0, MAX_RESPONSE_LIMITATIONS * 2)) {
|
|
170
|
-
const normalized = text(item, MAX_RESPONSE_LIMITATION_LENGTH);
|
|
171
|
-
if (normalized)
|
|
172
|
-
unique.add(normalized);
|
|
173
|
-
if (unique.size >= MAX_RESPONSE_LIMITATIONS)
|
|
174
|
-
break;
|
|
175
|
-
}
|
|
176
|
-
return [...unique];
|
|
177
|
-
}
|
|
178
|
-
function compareCodeUnits(left, right) {
|
|
179
|
-
return left < right ? -1 : left > right ? 1 : 0;
|
|
180
|
-
}
|
|
181
|
-
function lifecycleLimitations(value) {
|
|
182
|
-
return limitations(value).sort(compareCodeUnits);
|
|
183
|
-
}
|
|
184
25
|
function sourceSchema(value) {
|
|
185
26
|
const item = record(value);
|
|
186
27
|
const collection = text(item.collection, 80);
|
|
@@ -241,19 +82,6 @@ function certificateObservation(value, pin, options = {}) {
|
|
|
241
82
|
namesComplete: item.namesComplete,
|
|
242
83
|
};
|
|
243
84
|
}
|
|
244
|
-
function uniqueIds(value, validIds) {
|
|
245
|
-
if (!Array.isArray(value))
|
|
246
|
-
return [];
|
|
247
|
-
const unique = new Set();
|
|
248
|
-
for (const item of value.slice(0, MAX_DECISION_PIN_REFERENCES * 2)) {
|
|
249
|
-
if (typeof item !== 'string' || !SAFE_ID_RE.test(item) || (validIds && !validIds.has(item)))
|
|
250
|
-
continue;
|
|
251
|
-
unique.add(item);
|
|
252
|
-
if (unique.size >= MAX_DECISION_PIN_REFERENCES)
|
|
253
|
-
break;
|
|
254
|
-
}
|
|
255
|
-
return [...unique];
|
|
256
|
-
}
|
|
257
85
|
function normalizeEvidenceRelations(value, legacyIds, validIds) {
|
|
258
86
|
const output = new Map();
|
|
259
87
|
if (Array.isArray(value)) {
|
|
@@ -299,7 +127,7 @@ function normalizePin(raw, fallback, options = {}) {
|
|
|
299
127
|
source: text(item.source, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_selected',
|
|
300
128
|
sourceState: text(item.sourceState, 40) || null,
|
|
301
129
|
sourceSchema: normalizedSourceSchema,
|
|
302
|
-
observedAt:
|
|
130
|
+
observedAt: optionalIso(item.observedAt, options),
|
|
303
131
|
collectionDepth: item.collectionDepth === 'deep' || item.collectionDepth === 'fast'
|
|
304
132
|
? item.collectionDepth
|
|
305
133
|
: 'unknown',
|
|
@@ -321,6 +149,24 @@ function normalizePin(raw, fallback, options = {}) {
|
|
|
321
149
|
value,
|
|
322
150
|
sourceSchema: normalizedSourceSchema,
|
|
323
151
|
}, options);
|
|
152
|
+
if ((options.sourceVersion ?? CASE_SCHEMA_VERSION) > PUBLISHED_V2_3_CASE_SCHEMA_VERSION
|
|
153
|
+
&& item.observationHostname !== undefined) {
|
|
154
|
+
if (typeof item.observationHostname !== 'string' || !isValidAsciiHostname(item.observationHostname)
|
|
155
|
+
|| item.observationHostname !== item.observationHostname.toLowerCase())
|
|
156
|
+
return null;
|
|
157
|
+
normalized.observationHostname = item.observationHostname;
|
|
158
|
+
}
|
|
159
|
+
if ((options.sourceVersion ?? CASE_SCHEMA_VERSION) > PUBLISHED_V2_3_CASE_SCHEMA_VERSION && item.webObservationMode !== undefined) {
|
|
160
|
+
if (item.webObservationMode !== 'selected_url')
|
|
161
|
+
return null;
|
|
162
|
+
normalized.webObservationMode = 'selected_url';
|
|
163
|
+
}
|
|
164
|
+
// Introduced in Case 15. Older public records must not acquire an identity
|
|
165
|
+
// merely because an unrecognised input field resembles a digest.
|
|
166
|
+
if ((options.sourceVersion ?? CASE_SCHEMA_VERSION) >= 15
|
|
167
|
+
&& typeof item.importContentSha256 === 'string' && SHA256_RE.test(item.importContentSha256)) {
|
|
168
|
+
normalized.importContentSha256 = item.importContentSha256;
|
|
169
|
+
}
|
|
324
170
|
return normalized;
|
|
325
171
|
}
|
|
326
172
|
export function normalizeCaseEvidencePins(raw, fallback, options = {}) {
|
|
@@ -337,6 +183,9 @@ export function normalizeCaseEvidencePins(raw, fallback, options = {}) {
|
|
|
337
183
|
.slice(-MAX_CASE_EVIDENCE_PINS);
|
|
338
184
|
}
|
|
339
185
|
export function appendCaseEvidencePin(current, raw, now) {
|
|
186
|
+
if (current.length >= MAX_CASE_EVIDENCE_PINS) {
|
|
187
|
+
throw new Error(`A Case can retain at most ${MAX_CASE_EVIDENCE_PINS} evidence pins. No existing evidence was removed.`);
|
|
188
|
+
}
|
|
340
189
|
const item = record(raw);
|
|
341
190
|
const created = normalizePin({ ...item, id: freshId('pin'), createdAt: now }, now);
|
|
342
191
|
if (!created)
|
|
@@ -363,10 +212,17 @@ function normalizeDecision(raw, fallback, validPinIds, options = {}) {
|
|
|
363
212
|
if (!summary || !rationale)
|
|
364
213
|
return null;
|
|
365
214
|
const createdAt = iso(item.createdAt, fallback, options);
|
|
215
|
+
const currentConfidence = typeof item.confidence === 'string' && DECISION_CONFIDENCE_LEVELS.has(item.confidence)
|
|
216
|
+
? item.confidence
|
|
217
|
+
: 'unknown';
|
|
366
218
|
return {
|
|
367
219
|
id: safeId(item.id, 'decision', { summary, rationale, createdAt }),
|
|
368
220
|
summary,
|
|
369
221
|
rationale,
|
|
222
|
+
confidence: options.sourceVersion != null && options.sourceVersion < 15 ? 'unknown' : currentConfidence,
|
|
223
|
+
confidenceBasis: options.sourceVersion != null && options.sourceVersion < 15
|
|
224
|
+
? ''
|
|
225
|
+
: text(item.confidenceBasis, MAX_RESPONSE_RATIONALE_LENGTH),
|
|
370
226
|
evidencePinIds: uniqueIds(item.evidencePinIds, validPinIds),
|
|
371
227
|
createdAt,
|
|
372
228
|
};
|
|
@@ -385,638 +241,21 @@ export function normalizeCaseDecisions(raw, fallback, validPinIds, options = {})
|
|
|
385
241
|
.slice(-MAX_CASE_DECISIONS);
|
|
386
242
|
}
|
|
387
243
|
export function appendCaseDecision(current, raw, now, validPinIds) {
|
|
244
|
+
if (current.length >= MAX_CASE_DECISIONS) {
|
|
245
|
+
throw new Error(`A Case can retain at most ${MAX_CASE_DECISIONS} decisions. No existing decision was removed.`);
|
|
246
|
+
}
|
|
388
247
|
const item = record(raw);
|
|
389
248
|
const created = normalizeDecision({ ...item, id: freshId('decision'), createdAt: now }, now, validPinIds);
|
|
390
249
|
if (!created)
|
|
391
250
|
throw new Error('A decision requires a summary and rationale.');
|
|
392
251
|
return normalizeCaseDecisions([...current, created], now, validPinIds);
|
|
393
252
|
}
|
|
394
|
-
const LEGACY_ACTION_STATE_MAP = Object.freeze({
|
|
395
|
-
planned: 'drafting',
|
|
396
|
-
ready_for_review: 'ready_for_review',
|
|
397
|
-
submitted: 'submitted',
|
|
398
|
-
acknowledged: 'acknowledged',
|
|
399
|
-
resolved: 'terminal',
|
|
400
|
-
closed: 'terminal',
|
|
401
|
-
});
|
|
402
|
-
const ACTION_TRANSITIONS = Object.freeze({
|
|
403
|
-
drafting: new Set(['ready_for_review', 'terminal']),
|
|
404
|
-
ready_for_review: new Set(['drafting', 'reviewed', 'terminal']),
|
|
405
|
-
reviewed: new Set(['drafting', 'authorised', 'terminal']),
|
|
406
|
-
authorised: new Set(['drafting', 'submitted', 'terminal']),
|
|
407
|
-
submitted: new Set(['submitted', 'acknowledged', 'terminal']),
|
|
408
|
-
acknowledged: new Set(['acknowledged', 'terminal']),
|
|
409
|
-
terminal: new Set(),
|
|
410
|
-
});
|
|
411
|
-
function boundedCounter(value) {
|
|
412
|
-
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
|
413
|
-
? Math.min(value, 1_000_000)
|
|
414
|
-
: 0;
|
|
415
|
-
}
|
|
416
|
-
function bytes(value) {
|
|
417
|
-
try {
|
|
418
|
-
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
419
|
-
}
|
|
420
|
-
catch {
|
|
421
|
-
return Number.POSITIVE_INFINITY;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
export function isLegalCaseActionTransition(previousState, nextState, sourceClass = 'analyst') {
|
|
425
|
-
if (previousState === null) {
|
|
426
|
-
return sourceClass === 'migration'
|
|
427
|
-
|| nextState === 'drafting' && (sourceClass === 'analyst' || sourceClass === 'browser_local');
|
|
428
|
-
}
|
|
429
|
-
if (!ACTION_TRANSITIONS[previousState].has(nextState))
|
|
430
|
-
return false;
|
|
431
|
-
if (sourceClass === 'provider' || sourceClass === 'import') {
|
|
432
|
-
return (previousState === 'submitted' || previousState === 'acknowledged')
|
|
433
|
-
&& (nextState === 'submitted' || nextState === 'acknowledged' || nextState === 'terminal');
|
|
434
|
-
}
|
|
435
|
-
if (sourceClass === 'browser_local') {
|
|
436
|
-
return nextState === 'drafting' && ['ready_for_review', 'reviewed', 'authorised'].includes(previousState);
|
|
437
|
-
}
|
|
438
|
-
return sourceClass === 'analyst';
|
|
439
|
-
}
|
|
440
|
-
function normalizeActionEvent(raw, actionId, options = {}) {
|
|
441
|
-
const item = record(raw);
|
|
442
|
-
const previousState = item.previousState === null
|
|
443
|
-
? null
|
|
444
|
-
: typeof item.previousState === 'string' && ACTION_STATES.has(item.previousState)
|
|
445
|
-
? item.previousState
|
|
446
|
-
: undefined;
|
|
447
|
-
const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
|
|
448
|
-
? item.nextState
|
|
449
|
-
: null;
|
|
450
|
-
const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
|
|
451
|
-
? item.sourceClass
|
|
452
|
-
: null;
|
|
453
|
-
const occurredAt = optionalIso(item.occurredAt, options);
|
|
454
|
-
if (previousState === undefined || !nextState || !sourceClass || !occurredAt
|
|
455
|
-
|| !isLegalCaseActionTransition(previousState, nextState, sourceClass)) {
|
|
456
|
-
return null;
|
|
457
|
-
}
|
|
458
|
-
const migrationSnapshot = sourceClass === 'migration' && previousState === null;
|
|
459
|
-
if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState)
|
|
460
|
-
&& sourceClass !== 'analyst' && !migrationSnapshot)
|
|
461
|
-
return null;
|
|
462
|
-
const provenance = text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || `${sourceClass}_record`;
|
|
463
|
-
const providerOutcome = typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
|
|
464
|
-
? item.providerOutcome
|
|
465
|
-
: null;
|
|
466
|
-
if (providerOutcome && !['submitted', 'acknowledged', 'terminal'].includes(nextState) && sourceClass !== 'migration') {
|
|
467
|
-
return null;
|
|
468
|
-
}
|
|
469
|
-
if (providerOutcome && previousState === 'authorised' && nextState === 'submitted' && !migrationSnapshot) {
|
|
470
|
-
return null;
|
|
471
|
-
}
|
|
472
|
-
if (providerOutcome === 'no_response' && sourceClass === 'provider')
|
|
473
|
-
return null;
|
|
474
|
-
if (nextState === 'terminal' && previousState !== null
|
|
475
|
-
&& ['drafting', 'ready_for_review', 'reviewed', 'authorised'].includes(previousState)
|
|
476
|
-
&& (sourceClass !== 'analyst' || providerOutcome !== 'withdrawn')) {
|
|
477
|
-
return null;
|
|
478
|
-
}
|
|
479
|
-
if (providerOutcome === 'withdrawn'
|
|
480
|
-
&& (nextState !== 'terminal' || (sourceClass !== 'analyst' && !migrationSnapshot)))
|
|
481
|
-
return null;
|
|
482
|
-
const candidateEvidencePinId = typeof item.evidencePinId === 'string' && SAFE_ID_RE.test(item.evidencePinId)
|
|
483
|
-
? item.evidencePinId
|
|
484
|
-
: null;
|
|
485
|
-
const evidencePinId = candidateEvidencePinId
|
|
486
|
-
&& (!options.validEvidencePinIds || options.validEvidencePinIds.has(candidateEvidencePinId))
|
|
487
|
-
? candidateEvidencePinId
|
|
488
|
-
: null;
|
|
489
|
-
const eventMaterial = {
|
|
490
|
-
previousState,
|
|
491
|
-
nextState,
|
|
492
|
-
occurredAt,
|
|
493
|
-
sourceClass,
|
|
494
|
-
provenance,
|
|
495
|
-
reference: text(item.reference, MAX_RESPONSE_REFERENCE_LENGTH) || null,
|
|
496
|
-
evidencePinId,
|
|
497
|
-
limitations: limitations([
|
|
498
|
-
...(item.evidencePinId != null && !evidencePinId ? ['A malformed or dangling evidence-pin reference was omitted from this transition.'] : []),
|
|
499
|
-
...limitations(item.limitations),
|
|
500
|
-
]),
|
|
501
|
-
providerOutcome,
|
|
502
|
-
outcomeDetail: text(item.outcomeDetail, MAX_RESPONSE_RATIONALE_LENGTH) || null,
|
|
503
|
-
originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
|
|
504
|
-
? item.originActionId
|
|
505
|
-
: null,
|
|
506
|
-
};
|
|
507
|
-
return {
|
|
508
|
-
id: safeId(item.id, 'action-event', { actionId, ...eventMaterial }),
|
|
509
|
-
...eventMaterial,
|
|
510
|
-
applied: false,
|
|
511
|
-
};
|
|
512
|
-
}
|
|
513
|
-
function legacyActionState(value) {
|
|
514
|
-
if (typeof value === 'string' && ACTION_STATES.has(value))
|
|
515
|
-
return value;
|
|
516
|
-
if (typeof value === 'string' && LEGACY_ACTION_STATE_MAP[value])
|
|
517
|
-
return LEGACY_ACTION_STATE_MAP[value];
|
|
518
|
-
return 'drafting';
|
|
519
|
-
}
|
|
520
|
-
function legacyActionEvent(item, actionId, createdAt, fallback, options) {
|
|
521
|
-
const nextState = legacyActionState(item.state);
|
|
522
|
-
const occurredAt = iso(item.updatedAt, createdAt || fallback, options);
|
|
523
|
-
const sourceVersion = options.sourceVersion;
|
|
524
|
-
const provenance = sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12
|
|
525
|
-
? 'case_v12_legacy_snapshot'
|
|
526
|
-
: 'legacy_action_snapshot';
|
|
527
|
-
const legacyState = typeof item.state === 'string' ? item.state : 'planned';
|
|
528
|
-
const migrationLimitation = sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12
|
|
529
|
-
? `Migrated from the Case v12 current state "${text(legacyState, 40)}"; pre-v13 transition history is unavailable.`
|
|
530
|
-
: 'Recovered a legacy current-state action; earlier transition history is unavailable.';
|
|
531
|
-
const eventMaterial = {
|
|
532
|
-
previousState: null,
|
|
533
|
-
nextState,
|
|
534
|
-
occurredAt,
|
|
535
|
-
sourceClass: 'migration',
|
|
536
|
-
provenance,
|
|
537
|
-
reference: text(item.reference, MAX_RESPONSE_REFERENCE_LENGTH) || null,
|
|
538
|
-
evidencePinId: null,
|
|
539
|
-
limitations: limitations([migrationLimitation, ...limitations(item.contactLimitations)]),
|
|
540
|
-
providerOutcome: !(sourceVersion !== null && sourceVersion !== undefined && sourceVersion <= 12)
|
|
541
|
-
&& typeof item.providerOutcome === 'string' && PROVIDER_OUTCOMES.has(item.providerOutcome)
|
|
542
|
-
? item.providerOutcome
|
|
543
|
-
: null,
|
|
544
|
-
outcomeDetail: text(item.outcome, MAX_RESPONSE_RATIONALE_LENGTH) || null,
|
|
545
|
-
originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
|
|
546
|
-
? item.originActionId
|
|
547
|
-
: null,
|
|
548
|
-
};
|
|
549
|
-
return {
|
|
550
|
-
id: deterministicId('action-event', { actionId, ...eventMaterial }),
|
|
551
|
-
...eventMaterial,
|
|
552
|
-
applied: true,
|
|
553
|
-
};
|
|
554
|
-
}
|
|
555
|
-
function currentActionRecoveryEvent(actionId, createdAt) {
|
|
556
|
-
const eventMaterial = {
|
|
557
|
-
previousState: null,
|
|
558
|
-
nextState: 'drafting',
|
|
559
|
-
occurredAt: createdAt,
|
|
560
|
-
sourceClass: 'browser_local',
|
|
561
|
-
provenance: 'case_v13_history_recovery',
|
|
562
|
-
reference: null,
|
|
563
|
-
evidencePinId: null,
|
|
564
|
-
limitations: ['The v13 action had no valid transition history. Mutable state, reference, and outcome projections were ignored.'],
|
|
565
|
-
providerOutcome: null,
|
|
566
|
-
outcomeDetail: null,
|
|
567
|
-
originActionId: null,
|
|
568
|
-
};
|
|
569
|
-
return {
|
|
570
|
-
id: deterministicId('action-event', { actionId, ...eventMaterial }),
|
|
571
|
-
...eventMaterial,
|
|
572
|
-
applied: true,
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
function actionEventContent(event) {
|
|
576
|
-
const { applied: _applied, ...material } = event;
|
|
577
|
-
return JSON.stringify(material);
|
|
578
|
-
}
|
|
579
|
-
function projectActionHistory(source, omitted) {
|
|
580
|
-
let state = null;
|
|
581
|
-
let conflicts = 0;
|
|
582
|
-
const history = source.map((event, index) => {
|
|
583
|
-
if (state === null && omitted > 0 && index === 0 && event.previousState !== null)
|
|
584
|
-
state = event.previousState;
|
|
585
|
-
const applied = event.previousState === state
|
|
586
|
-
&& isLegalCaseActionTransition(event.previousState, event.nextState, event.sourceClass);
|
|
587
|
-
if (applied)
|
|
588
|
-
state = event.nextState;
|
|
589
|
-
else
|
|
590
|
-
conflicts += 1;
|
|
591
|
-
return { ...event, applied };
|
|
592
|
-
});
|
|
593
|
-
return { history, state: state ?? 'drafting', conflicts };
|
|
594
|
-
}
|
|
595
|
-
function normalizeActionHistory(raw, item, actionId, createdAt, fallback, options) {
|
|
596
|
-
const source = Array.isArray(raw) ? raw : [];
|
|
597
|
-
let omitted = boundedCounter(item.historyOmitted);
|
|
598
|
-
let invalid = 0;
|
|
599
|
-
let duplicateConflict = 0;
|
|
600
|
-
const byId = new Map();
|
|
601
|
-
for (const candidate of source.slice(0, MAX_CASE_ACTION_EVENTS_PER_ACTION * 4)) {
|
|
602
|
-
const event = normalizeActionEvent(candidate, actionId, options);
|
|
603
|
-
if (!event) {
|
|
604
|
-
invalid += 1;
|
|
605
|
-
continue;
|
|
606
|
-
}
|
|
607
|
-
const existing = byId.get(event.id);
|
|
608
|
-
if (!existing)
|
|
609
|
-
byId.set(event.id, event);
|
|
610
|
-
else if (actionEventContent(existing) !== actionEventContent(event)) {
|
|
611
|
-
duplicateConflict += 1;
|
|
612
|
-
if (actionEventContent(event) < actionEventContent(existing))
|
|
613
|
-
byId.set(event.id, event);
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
invalid += Math.max(0, source.length - MAX_CASE_ACTION_EVENTS_PER_ACTION * 4);
|
|
617
|
-
if (!byId.size) {
|
|
618
|
-
const recovered = options.sourceVersion != null && options.sourceVersion <= 12
|
|
619
|
-
? legacyActionEvent(item, actionId, createdAt, fallback, options)
|
|
620
|
-
: currentActionRecoveryEvent(actionId, createdAt);
|
|
621
|
-
byId.set(recovered.id, recovered);
|
|
622
|
-
}
|
|
623
|
-
let history = [...byId.values()].sort((left, right) => Date.parse(left.occurredAt) - Date.parse(right.occurredAt) || compareCodeUnits(left.id, right.id));
|
|
624
|
-
if (history.length > MAX_CASE_ACTION_EVENTS_PER_ACTION) {
|
|
625
|
-
omitted += history.length - MAX_CASE_ACTION_EVENTS_PER_ACTION;
|
|
626
|
-
history = history.slice(-MAX_CASE_ACTION_EVENTS_PER_ACTION);
|
|
627
|
-
}
|
|
628
|
-
while (history.length > 1 && bytes(history) > MAX_CASE_ACTION_BYTES) {
|
|
629
|
-
history.shift();
|
|
630
|
-
omitted += 1;
|
|
631
|
-
}
|
|
632
|
-
const totalOmitted = omitted + invalid + duplicateConflict;
|
|
633
|
-
const projected = projectActionHistory(history, totalOmitted);
|
|
634
|
-
const retainedLimitations = limitations(item.historyLimitations).filter((item) => !/^\d+ earlier action transition events? omitted by bounded retention\.$/u.test(item));
|
|
635
|
-
const historyLimitations = lifecycleLimitations([
|
|
636
|
-
...(totalOmitted ? [`${totalOmitted} earlier action transition event${totalOmitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
|
|
637
|
-
...(invalid ? [`${invalid} malformed or illegal action transition event${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
|
|
638
|
-
...(duplicateConflict ? [`${duplicateConflict} conflicting duplicate event identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
|
|
639
|
-
...(projected.conflicts ? [`${projected.conflicts} retained concurrent transition${projected.conflicts === 1 ? ' is' : 's are'} not applied to the current-state projection.`] : []),
|
|
640
|
-
...retainedLimitations,
|
|
641
|
-
]);
|
|
642
|
-
return {
|
|
643
|
-
history: projected.history,
|
|
644
|
-
state: projected.state,
|
|
645
|
-
omitted: totalOmitted,
|
|
646
|
-
historyLimitations,
|
|
647
|
-
};
|
|
648
|
-
}
|
|
649
|
-
function normalizeAction(raw, fallback, options = {}) {
|
|
650
|
-
const item = record(raw);
|
|
651
|
-
const recipient = text(item.recipient, MAX_RESPONSE_RECIPIENT_LENGTH);
|
|
652
|
-
if (!recipient)
|
|
653
|
-
return null;
|
|
654
|
-
const createdAt = iso(item.createdAt, fallback, options);
|
|
655
|
-
const actionId = safeId(item.id, 'action', { recipient, createdAt });
|
|
656
|
-
const history = normalizeActionHistory(item.history, item, actionId, createdAt, fallback, options);
|
|
657
|
-
const applied = history.history.filter((event) => event.applied);
|
|
658
|
-
const latestReference = [...applied].reverse().find((event) => event.reference)?.reference ?? null;
|
|
659
|
-
const latestProviderOutcome = [...applied].reverse().find((event) => event.providerOutcome) ?? null;
|
|
660
|
-
const latestOutcomeDetail = [...applied].reverse().find((event) => event.outcomeDetail) ?? null;
|
|
661
|
-
const latestEventAt = applied.at(-1)?.occurredAt ?? createdAt;
|
|
662
|
-
const metadataUpdatedAt = iso(item.metadataUpdatedAt ?? item.updatedAt, createdAt, options);
|
|
663
|
-
const updatedAt = Date.parse(latestEventAt) > Date.parse(metadataUpdatedAt) ? latestEventAt : metadataUpdatedAt;
|
|
664
|
-
return {
|
|
665
|
-
id: actionId,
|
|
666
|
-
type: typeof item.type === 'string' && ACTION_TYPES.has(item.type)
|
|
667
|
-
? item.type
|
|
668
|
-
: 'internal_review',
|
|
669
|
-
recipient,
|
|
670
|
-
contactSource: text(item.contactSource, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_supplied',
|
|
671
|
-
routeObservedAt: options.sourceVersion != null && options.sourceVersion < 14
|
|
672
|
-
? null
|
|
673
|
-
: optionalIso(item.routeObservedAt, options),
|
|
674
|
-
contactLimitations: limitations(item.contactLimitations),
|
|
675
|
-
dueAt: optionalIso(item.dueAt, options),
|
|
676
|
-
state: history.state,
|
|
677
|
-
reference: latestReference,
|
|
678
|
-
followUpAt: optionalIso(item.followUpAt, options),
|
|
679
|
-
providerOutcome: latestProviderOutcome?.providerOutcome ?? null,
|
|
680
|
-
outcome: latestProviderOutcome?.outcomeDetail ?? latestOutcomeDetail?.outcomeDetail ?? null,
|
|
681
|
-
originActionId: typeof item.originActionId === 'string' && SAFE_ID_RE.test(item.originActionId) && item.originActionId !== actionId
|
|
682
|
-
? item.originActionId
|
|
683
|
-
: null,
|
|
684
|
-
history: history.history,
|
|
685
|
-
historyOmitted: history.omitted,
|
|
686
|
-
historyLimitations: history.historyLimitations,
|
|
687
|
-
createdAt,
|
|
688
|
-
metadataUpdatedAt,
|
|
689
|
-
updatedAt,
|
|
690
|
-
};
|
|
691
|
-
}
|
|
692
|
-
function mergeNormalizedActions(left, right, fallback) {
|
|
693
|
-
const metadataWinner = Date.parse(left.metadataUpdatedAt) > Date.parse(right.metadataUpdatedAt)
|
|
694
|
-
? left
|
|
695
|
-
: Date.parse(right.metadataUpdatedAt) > Date.parse(left.metadataUpdatedAt)
|
|
696
|
-
? right
|
|
697
|
-
: JSON.stringify(left) <= JSON.stringify(right) ? left : right;
|
|
698
|
-
return normalizeAction({
|
|
699
|
-
...metadataWinner,
|
|
700
|
-
id: left.id,
|
|
701
|
-
createdAt: Date.parse(left.createdAt) <= Date.parse(right.createdAt) ? left.createdAt : right.createdAt,
|
|
702
|
-
history: [...left.history, ...right.history],
|
|
703
|
-
historyOmitted: Math.max(left.historyOmitted, right.historyOmitted),
|
|
704
|
-
historyLimitations: [...left.historyLimitations, ...right.historyLimitations],
|
|
705
|
-
}, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
706
|
-
}
|
|
707
|
-
function boundActionCollection(actions, fallback) {
|
|
708
|
-
const keep = new Set();
|
|
709
|
-
for (const action of actions) {
|
|
710
|
-
const latest = action.history.at(-1);
|
|
711
|
-
if (latest)
|
|
712
|
-
keep.add(`${action.id}\u0000${latest.id}`);
|
|
713
|
-
}
|
|
714
|
-
const all = actions.flatMap((action) => action.history.map((event) => ({ action, event })))
|
|
715
|
-
.sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt)
|
|
716
|
-
|| compareCodeUnits(right.event.id, left.event.id));
|
|
717
|
-
for (const item of all) {
|
|
718
|
-
if (keep.size >= MAX_CASE_ACTION_EVENTS_PER_CASE)
|
|
719
|
-
break;
|
|
720
|
-
keep.add(`${item.action.id}\u0000${item.event.id}`);
|
|
721
|
-
}
|
|
722
|
-
let bounded = actions.map((action) => {
|
|
723
|
-
const history = action.history.filter((event) => keep.has(`${action.id}\u0000${event.id}`));
|
|
724
|
-
const omitted = action.historyOmitted + action.history.length - history.length;
|
|
725
|
-
return normalizeAction({ ...action, history, historyOmitted: omitted }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
726
|
-
});
|
|
727
|
-
while (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE) {
|
|
728
|
-
const candidate = bounded
|
|
729
|
-
.flatMap((action) => action.history.slice(0, -1).map((event) => ({ action, event })))
|
|
730
|
-
.sort((left, right) => Date.parse(left.event.occurredAt) - Date.parse(right.event.occurredAt)
|
|
731
|
-
|| compareCodeUnits(left.event.id, right.event.id))[0];
|
|
732
|
-
if (!candidate)
|
|
733
|
-
break;
|
|
734
|
-
bounded = bounded.map((action) => action.id !== candidate.action.id
|
|
735
|
-
? action
|
|
736
|
-
: normalizeAction({
|
|
737
|
-
...action,
|
|
738
|
-
history: action.history.filter((event) => event.id !== candidate.event.id),
|
|
739
|
-
historyOmitted: action.historyOmitted + 1,
|
|
740
|
-
}, fallback, { sourceVersion: CASE_SCHEMA_VERSION }));
|
|
741
|
-
}
|
|
742
|
-
if (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE && bounded.length > 1) {
|
|
743
|
-
const byteOmissionPattern = /^(\d+) earlier response actions? omitted by the per-Case response-history byte bound\.$/u;
|
|
744
|
-
const previouslyOmitted = bounded.reduce((maximum, action) => {
|
|
745
|
-
const retainedCount = action.historyLimitations.reduce((count, item) => {
|
|
746
|
-
const match = byteOmissionPattern.exec(item);
|
|
747
|
-
return match ? Math.max(count, Number(match[1])) : count;
|
|
748
|
-
}, 0);
|
|
749
|
-
return Math.max(maximum, retainedCount);
|
|
750
|
-
}, 0);
|
|
751
|
-
bounded = bounded.map((action) => ({
|
|
752
|
-
...action,
|
|
753
|
-
historyLimitations: action.historyLimitations.filter((item) => !byteOmissionPattern.test(item)),
|
|
754
|
-
}));
|
|
755
|
-
let omittedActions = 0;
|
|
756
|
-
while (bounded.length > 1) {
|
|
757
|
-
bounded = bounded.slice(1);
|
|
758
|
-
omittedActions += 1;
|
|
759
|
-
const first = bounded[0];
|
|
760
|
-
const totalOmitted = previouslyOmitted + omittedActions;
|
|
761
|
-
bounded = [{
|
|
762
|
-
...first,
|
|
763
|
-
historyLimitations: lifecycleLimitations([
|
|
764
|
-
`${totalOmitted} earlier response action${totalOmitted === 1 ? '' : 's'} omitted by the per-Case response-history byte bound.`,
|
|
765
|
-
...first.historyLimitations,
|
|
766
|
-
]),
|
|
767
|
-
}, ...bounded.slice(1)];
|
|
768
|
-
if (bytes(bounded) <= MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE)
|
|
769
|
-
return bounded;
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
return bounded;
|
|
773
|
-
}
|
|
774
|
-
export function normalizeCaseActions(raw, fallback, options = {}) {
|
|
775
|
-
if (!Array.isArray(raw))
|
|
776
|
-
return [];
|
|
777
|
-
const byId = new Map();
|
|
778
|
-
const inspected = raw.slice(0, MAX_CASE_ACTIONS * 2);
|
|
779
|
-
let invalid = 0;
|
|
780
|
-
for (const item of inspected) {
|
|
781
|
-
const normalized = normalizeAction(item, fallback, options);
|
|
782
|
-
if (!normalized) {
|
|
783
|
-
invalid += 1;
|
|
784
|
-
continue;
|
|
785
|
-
}
|
|
786
|
-
const existing = byId.get(normalized.id);
|
|
787
|
-
byId.set(normalized.id, existing ? mergeNormalizedActions(existing, normalized, fallback) : normalized);
|
|
788
|
-
}
|
|
789
|
-
const normalizedActions = [...byId.values()]
|
|
790
|
-
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id));
|
|
791
|
-
const uninspected = Math.max(0, raw.length - inspected.length);
|
|
792
|
-
const omitted = Math.max(0, normalizedActions.length - MAX_CASE_ACTIONS);
|
|
793
|
-
const actions = normalizedActions.slice(-MAX_CASE_ACTIONS);
|
|
794
|
-
if (actions.length && (invalid || uninspected || omitted)) {
|
|
795
|
-
const first = actions[0];
|
|
796
|
-
actions[0] = {
|
|
797
|
-
...first,
|
|
798
|
-
historyLimitations: lifecycleLimitations([
|
|
799
|
-
...(invalid ? [`${invalid} malformed response-action candidate${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
|
|
800
|
-
...(uninspected ? [`${uninspected} response-action candidate${uninspected === 1 ? '' : 's'} beyond the bounded inspection window were not traversed.`] : []),
|
|
801
|
-
...(omitted ? [`${omitted} earlier response action${omitted === 1 ? '' : 's'} omitted by the per-Case action bound.`] : []),
|
|
802
|
-
...first.historyLimitations,
|
|
803
|
-
]),
|
|
804
|
-
};
|
|
805
|
-
}
|
|
806
|
-
const actionIds = new Set(actions.map((action) => action.id));
|
|
807
|
-
const linked = actions.map((action) => {
|
|
808
|
-
const originActionId = action.originActionId && actionIds.has(action.originActionId) && action.originActionId !== action.id
|
|
809
|
-
? action.originActionId
|
|
810
|
-
: null;
|
|
811
|
-
const invalidOrigins = action.history.filter((event) => event.originActionId
|
|
812
|
-
&& (!actionIds.has(event.originActionId) || event.originActionId === action.id)).length;
|
|
813
|
-
return {
|
|
814
|
-
...action,
|
|
815
|
-
originActionId,
|
|
816
|
-
history: action.history.map((event) => ({
|
|
817
|
-
...event,
|
|
818
|
-
originActionId: event.originActionId && actionIds.has(event.originActionId) && event.originActionId !== action.id
|
|
819
|
-
? event.originActionId
|
|
820
|
-
: null,
|
|
821
|
-
})),
|
|
822
|
-
historyLimitations: lifecycleLimitations([
|
|
823
|
-
...action.historyLimitations,
|
|
824
|
-
...(invalidOrigins || (action.originActionId && !originActionId)
|
|
825
|
-
? ['One or more dangling or self-referential origin-action links were omitted.']
|
|
826
|
-
: []),
|
|
827
|
-
]),
|
|
828
|
-
};
|
|
829
|
-
});
|
|
830
|
-
return boundActionCollection(linked, fallback);
|
|
831
|
-
}
|
|
832
|
-
export function appendCaseAction(current, raw, now) {
|
|
833
|
-
if (current.length >= MAX_CASE_ACTIONS) {
|
|
834
|
-
throw new Error(`A Case can retain at most ${MAX_CASE_ACTIONS} response actions. No additional action was retained.`);
|
|
835
|
-
}
|
|
836
|
-
const item = record(raw);
|
|
837
|
-
const id = freshId('action');
|
|
838
|
-
const originActionId = typeof item.originActionId === 'string' && current.some((action) => action.id === item.originActionId)
|
|
839
|
-
? item.originActionId
|
|
840
|
-
: null;
|
|
841
|
-
if (item.originActionId != null && !originActionId)
|
|
842
|
-
throw new Error('A follow-on action requires an existing originating action.');
|
|
843
|
-
const history = [{
|
|
844
|
-
id: freshId('action-event'),
|
|
845
|
-
previousState: null,
|
|
846
|
-
nextState: 'drafting',
|
|
847
|
-
occurredAt: now,
|
|
848
|
-
sourceClass: 'analyst',
|
|
849
|
-
provenance: 'browser_local_action_creation',
|
|
850
|
-
reference: null,
|
|
851
|
-
evidencePinId: null,
|
|
852
|
-
limitations: [],
|
|
853
|
-
providerOutcome: null,
|
|
854
|
-
outcomeDetail: null,
|
|
855
|
-
originActionId,
|
|
856
|
-
}];
|
|
857
|
-
const created = normalizeAction({
|
|
858
|
-
...item,
|
|
859
|
-
id,
|
|
860
|
-
originActionId,
|
|
861
|
-
history,
|
|
862
|
-
historyOmitted: 0,
|
|
863
|
-
createdAt: now,
|
|
864
|
-
metadataUpdatedAt: now,
|
|
865
|
-
}, now, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
866
|
-
if (!created)
|
|
867
|
-
throw new Error('An action requires a recipient or internal owner.');
|
|
868
|
-
return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
|
|
869
|
-
}
|
|
870
|
-
export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
|
|
871
|
-
const action = current.find((item) => item.id === actionId);
|
|
872
|
-
if (!action)
|
|
873
|
-
throw new Error('That case action no longer exists.');
|
|
874
|
-
const item = record(raw);
|
|
875
|
-
const nextState = typeof item.nextState === 'string' && ACTION_STATES.has(item.nextState)
|
|
876
|
-
? item.nextState
|
|
877
|
-
: null;
|
|
878
|
-
const sourceClass = typeof item.sourceClass === 'string' && ACTION_EVENT_SOURCE_CLASSES.has(item.sourceClass)
|
|
879
|
-
? item.sourceClass
|
|
880
|
-
: 'analyst';
|
|
881
|
-
if (!nextState || !isLegalCaseActionTransition(action.state, nextState, sourceClass)) {
|
|
882
|
-
throw new Error(`The transition from ${action.state.replaceAll('_', ' ')} to ${String(item.nextState || 'that state').replaceAll('_', ' ')} is not permitted.`);
|
|
883
|
-
}
|
|
884
|
-
if (['ready_for_review', 'reviewed', 'authorised', 'submitted'].includes(nextState) && sourceClass !== 'analyst') {
|
|
885
|
-
throw new Error('Readiness, review, authorisation, and submission require an explicit analyst transition.');
|
|
886
|
-
}
|
|
887
|
-
const originActionId = typeof item.originActionId === 'string'
|
|
888
|
-
&& item.originActionId !== action.id
|
|
889
|
-
&& current.some((candidate) => candidate.id === item.originActionId)
|
|
890
|
-
? item.originActionId
|
|
891
|
-
: action.originActionId;
|
|
892
|
-
if (item.originActionId != null && originActionId !== item.originActionId) {
|
|
893
|
-
throw new Error('A referral or follow-on transition requires a distinct existing originating action.');
|
|
894
|
-
}
|
|
895
|
-
if (item.evidencePinId != null && (typeof item.evidencePinId !== 'string'
|
|
896
|
-
|| !SAFE_ID_RE.test(item.evidencePinId) || (validPinIds && !validPinIds.has(item.evidencePinId)))) {
|
|
897
|
-
throw new Error('An action transition evidence pin must reference a retained Case evidence pin.');
|
|
898
|
-
}
|
|
899
|
-
const occurredAt = optionalIso(item.occurredAt) ?? now;
|
|
900
|
-
const event = normalizeActionEvent({
|
|
901
|
-
...item,
|
|
902
|
-
id: freshId('action-event'),
|
|
903
|
-
previousState: action.state,
|
|
904
|
-
nextState,
|
|
905
|
-
occurredAt,
|
|
906
|
-
sourceClass,
|
|
907
|
-
provenance: text(item.provenance, MAX_RESPONSE_LABEL_LENGTH) || 'browser_local_explicit_transition',
|
|
908
|
-
originActionId,
|
|
909
|
-
}, action.id, currentActionNormalizationOptions(validPinIds));
|
|
910
|
-
if (!event)
|
|
911
|
-
throw new Error('The action transition contains an invalid time, provider outcome, or provenance field.');
|
|
912
|
-
const updated = normalizeAction({
|
|
913
|
-
...action,
|
|
914
|
-
history: [...action.history, event],
|
|
915
|
-
historyOmitted: action.historyOmitted,
|
|
916
|
-
}, now, currentActionNormalizationOptions(validPinIds));
|
|
917
|
-
return normalizeCaseActions(current.map((candidate) => candidate.id === actionId ? updated : candidate), now, currentActionNormalizationOptions(validPinIds));
|
|
918
|
-
}
|
|
919
|
-
export function updateCaseAction(current, raw, now, validPinIds) {
|
|
920
|
-
const patch = record(raw);
|
|
921
|
-
const id = typeof patch.id === 'string' && SAFE_ID_RE.test(patch.id) ? patch.id : '';
|
|
922
|
-
const existing = current.find((item) => item.id === id);
|
|
923
|
-
if (!existing)
|
|
924
|
-
throw new Error('That case action no longer exists.');
|
|
925
|
-
if (Object.hasOwn(patch, 'state') || Object.hasOwn(patch, 'outcome') || Object.hasOwn(patch, 'providerOutcome') || Object.hasOwn(patch, 'reference')) {
|
|
926
|
-
throw new Error('State, reference, and provider outcomes must be recorded as an append-only action transition.');
|
|
927
|
-
}
|
|
928
|
-
const metadata = {
|
|
929
|
-
...existing,
|
|
930
|
-
type: Object.hasOwn(patch, 'type') ? patch.type : existing.type,
|
|
931
|
-
recipient: Object.hasOwn(patch, 'recipient') ? patch.recipient : existing.recipient,
|
|
932
|
-
contactSource: Object.hasOwn(patch, 'contactSource') ? patch.contactSource : existing.contactSource,
|
|
933
|
-
routeObservedAt: Object.hasOwn(patch, 'routeObservedAt') ? patch.routeObservedAt : existing.routeObservedAt,
|
|
934
|
-
contactLimitations: Object.hasOwn(patch, 'contactLimitations') ? patch.contactLimitations : existing.contactLimitations,
|
|
935
|
-
dueAt: Object.hasOwn(patch, 'dueAt') ? patch.dueAt : existing.dueAt,
|
|
936
|
-
followUpAt: Object.hasOwn(patch, 'followUpAt') ? patch.followUpAt : existing.followUpAt,
|
|
937
|
-
originActionId: Object.hasOwn(patch, 'originActionId') ? patch.originActionId : existing.originActionId,
|
|
938
|
-
metadataUpdatedAt: now,
|
|
939
|
-
};
|
|
940
|
-
if (Object.hasOwn(patch, 'originActionId') && patch.originActionId !== null
|
|
941
|
-
&& (typeof patch.originActionId !== 'string' || patch.originActionId === id || !current.some((item) => item.id === patch.originActionId))) {
|
|
942
|
-
throw new Error('A follow-on action requires a distinct existing originating action.');
|
|
943
|
-
}
|
|
944
|
-
let updated = normalizeAction(metadata, now, currentActionNormalizationOptions(validPinIds));
|
|
945
|
-
if (!updated)
|
|
946
|
-
throw new Error('An action requires a recipient or internal owner.');
|
|
947
|
-
const materialChanged = ['type', 'recipient', 'contactSource', 'routeObservedAt', 'contactLimitations', 'originActionId']
|
|
948
|
-
.some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
|
|
949
|
-
if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
|
|
950
|
-
throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');
|
|
951
|
-
}
|
|
952
|
-
if (materialChanged && ['ready_for_review', 'reviewed', 'authorised'].includes(existing.state)) {
|
|
953
|
-
const invalidated = normalizeAction({
|
|
954
|
-
...updated,
|
|
955
|
-
history: [...updated.history, {
|
|
956
|
-
id: freshId('action-event'),
|
|
957
|
-
previousState: existing.state,
|
|
958
|
-
nextState: 'drafting',
|
|
959
|
-
occurredAt: now,
|
|
960
|
-
sourceClass: 'browser_local',
|
|
961
|
-
provenance: 'material_action_change',
|
|
962
|
-
reference: null,
|
|
963
|
-
evidencePinId: null,
|
|
964
|
-
limitations: ['Material action inputs changed after review; prior readiness, review, or authorisation no longer applies.'],
|
|
965
|
-
providerOutcome: null,
|
|
966
|
-
outcomeDetail: null,
|
|
967
|
-
originActionId: updated.originActionId,
|
|
968
|
-
}],
|
|
969
|
-
}, now, currentActionNormalizationOptions(validPinIds));
|
|
970
|
-
if (!invalidated)
|
|
971
|
-
throw new Error('The material action change could not be retained safely.');
|
|
972
|
-
updated = invalidated;
|
|
973
|
-
}
|
|
974
|
-
const interim = current.map((item) => item.id === id ? updated : item);
|
|
975
|
-
return patch.transition !== undefined
|
|
976
|
-
? appendCaseActionTransition(interim, id, patch.transition, now, validPinIds)
|
|
977
|
-
: normalizeCaseActions(interim, now, currentActionNormalizationOptions(validPinIds));
|
|
978
|
-
}
|
|
979
|
-
export function buildCaseActionOutcomeSummary(actions, nowRaw = new Date().toISOString()) {
|
|
980
|
-
const now = optionalIso(nowRaw) ?? new Date().toISOString();
|
|
981
|
-
const nowTime = Date.parse(now);
|
|
982
|
-
const active = actions.filter((item) => item.state !== 'terminal');
|
|
983
|
-
const latestOutcomes = actions
|
|
984
|
-
.flatMap((action) => action.history.filter((event) => event.applied && event.providerOutcome).map((event) => ({ action, event })))
|
|
985
|
-
.sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt) || compareCodeUnits(right.event.id, left.event.id))
|
|
986
|
-
.slice(0, 5)
|
|
987
|
-
.map(({ action, event }) => ({
|
|
988
|
-
actionId: action.id,
|
|
989
|
-
recipient: action.recipient,
|
|
990
|
-
state: action.state,
|
|
991
|
-
providerOutcome: event.providerOutcome,
|
|
992
|
-
outcomeDetail: event.outcomeDetail,
|
|
993
|
-
occurredAt: event.occurredAt,
|
|
994
|
-
}));
|
|
995
|
-
return {
|
|
996
|
-
total: actions.length,
|
|
997
|
-
active: active.length,
|
|
998
|
-
drafting: actions.filter((item) => item.state === 'drafting').length,
|
|
999
|
-
readyForReview: actions.filter((item) => item.state === 'ready_for_review').length,
|
|
1000
|
-
reviewed: actions.filter((item) => item.state === 'reviewed').length,
|
|
1001
|
-
authorised: actions.filter((item) => item.state === 'authorised').length,
|
|
1002
|
-
submitted: actions.filter((item) => item.state === 'submitted').length,
|
|
1003
|
-
acknowledged: actions.filter((item) => item.state === 'acknowledged').length,
|
|
1004
|
-
terminal: actions.filter((item) => item.state === 'terminal').length,
|
|
1005
|
-
overdue: active.filter((item) => item.dueAt && Date.parse(item.dueAt) < nowTime).length,
|
|
1006
|
-
followUpDue: active.filter((item) => item.followUpAt && Date.parse(item.followUpAt) <= nowTime).length,
|
|
1007
|
-
withProviderOutcome: actions.filter((item) => item.providerOutcome !== null).length,
|
|
1008
|
-
latestOutcomes,
|
|
1009
|
-
};
|
|
1010
|
-
}
|
|
1011
253
|
export function mergeCaseEvidencePins(local, imported, fallback) {
|
|
1012
254
|
return normalizeCaseEvidencePins([...local, ...imported], fallback);
|
|
1013
255
|
}
|
|
1014
256
|
export function mergeCaseDecisions(local, imported, fallback, validPinIds) {
|
|
1015
257
|
return normalizeCaseDecisions([...local, ...imported], fallback, validPinIds);
|
|
1016
258
|
}
|
|
1017
|
-
export function mergeCaseActions(local, imported, fallback, validPinIds) {
|
|
1018
|
-
return normalizeCaseActions([...local, ...imported], fallback, currentActionNormalizationOptions(validPinIds));
|
|
1019
|
-
}
|
|
1020
259
|
function assertionProvenanceList(value, maximum) {
|
|
1021
260
|
if (!Array.isArray(value))
|
|
1022
261
|
return [];
|
|
@@ -1077,6 +316,10 @@ function normalizeAssertion(raw, fallback, validPinIds, options = {}) {
|
|
|
1077
316
|
const provenance = normalizeAssertionProvenance(item.provenance, options);
|
|
1078
317
|
const legacyIds = uniqueIds(item.evidencePinIds, validPinIds);
|
|
1079
318
|
const evidenceRelations = normalizeEvidenceRelations(item.evidenceRelations, legacyIds, validPinIds);
|
|
319
|
+
const recheck = readCaseRecheckContext(item.recheck, options.sourceVersion);
|
|
320
|
+
if (recheck && item.kind !== 'next_step') {
|
|
321
|
+
throw new TypeError('A recheck question must remain a next step.');
|
|
322
|
+
}
|
|
1080
323
|
return {
|
|
1081
324
|
id: safeId(item.id, 'assertion', { statement, createdAt }),
|
|
1082
325
|
kind: typeof item.kind === 'string' && ASSERTION_KINDS.has(item.kind)
|
|
@@ -1092,6 +335,7 @@ function normalizeAssertion(raw, fallback, validPinIds, options = {}) {
|
|
|
1092
335
|
createdAt,
|
|
1093
336
|
updatedAt: iso(item.updatedAt, createdAt, options),
|
|
1094
337
|
...(provenance ? { provenance } : {}),
|
|
338
|
+
...(recheck ? { recheck } : {}),
|
|
1095
339
|
};
|
|
1096
340
|
}
|
|
1097
341
|
export function normalizeCaseAssertions(raw, fallback, validPinIds, options = {}) {
|
|
@@ -1112,6 +356,9 @@ export function normalizeCaseAssertions(raw, fallback, validPinIds, options = {}
|
|
|
1112
356
|
.slice(-MAX_CASE_ASSERTIONS);
|
|
1113
357
|
}
|
|
1114
358
|
export function appendCaseAssertion(current, raw, now, validPinIds) {
|
|
359
|
+
if (current.length >= MAX_CASE_ASSERTIONS) {
|
|
360
|
+
throw new Error(`A Case can retain at most ${MAX_CASE_ASSERTIONS} assertions. No existing assertion was removed.`);
|
|
361
|
+
}
|
|
1115
362
|
const item = record(raw);
|
|
1116
363
|
const created = normalizeAssertion({
|
|
1117
364
|
...item,
|
|
@@ -1129,6 +376,8 @@ export function updateCaseAssertion(current, raw, now, validPinIds) {
|
|
|
1129
376
|
const existing = current.find((item) => item.id === id);
|
|
1130
377
|
if (!existing)
|
|
1131
378
|
throw new Error('That analyst assertion no longer exists.');
|
|
379
|
+
if (patch.expectedUpdatedAt !== undefined && patch.expectedUpdatedAt !== existing.updatedAt)
|
|
380
|
+
throw new Error('The analyst assertion changed in another tab. Refresh before updating it.');
|
|
1132
381
|
const updated = normalizeAssertion({
|
|
1133
382
|
...existing,
|
|
1134
383
|
...patch,
|
|
@@ -1170,6 +419,9 @@ export function normalizeCaseManualTrail(raw, fallback, options = {}) {
|
|
|
1170
419
|
.slice(-MAX_CASE_MANUAL_TRAIL_EVENTS);
|
|
1171
420
|
}
|
|
1172
421
|
export function appendCaseManualTrailEvent(current, raw, now) {
|
|
422
|
+
if (current.length >= MAX_CASE_MANUAL_TRAIL_EVENTS) {
|
|
423
|
+
throw new Error(`A Case can retain at most ${MAX_CASE_MANUAL_TRAIL_EVENTS} investigation-trail entries. No existing entry was removed.`);
|
|
424
|
+
}
|
|
1173
425
|
const item = record(raw);
|
|
1174
426
|
const created = normalizeManualTrailEvent({ ...item, id: freshId('trail'), createdAt: now }, now);
|
|
1175
427
|
if (!created)
|
|
@@ -1211,7 +463,7 @@ function normalizeCaseSighting(raw, fallback, validPinIds, options = {}) {
|
|
|
1211
463
|
? item.category
|
|
1212
464
|
: 'other',
|
|
1213
465
|
source,
|
|
1214
|
-
observedAt:
|
|
466
|
+
observedAt: optionalIso(item.observedAt, options),
|
|
1215
467
|
completeness: typeof item.completeness === 'string' && COMPLETENESS.has(item.completeness)
|
|
1216
468
|
? item.completeness
|
|
1217
469
|
: 'unknown',
|
|
@@ -1230,10 +482,13 @@ export function normalizeCaseSightings(raw, fallback, validPinIds, options = {})
|
|
|
1230
482
|
byId.set(normalized.id, normalized);
|
|
1231
483
|
}
|
|
1232
484
|
return [...byId.values()]
|
|
1233
|
-
.sort((left, right) => Date.parse(left.
|
|
485
|
+
.sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id))
|
|
1234
486
|
.slice(-MAX_CASE_SIGHTINGS);
|
|
1235
487
|
}
|
|
1236
488
|
export function appendCaseSighting(current, raw, now, validPinIds) {
|
|
489
|
+
if (current.length >= MAX_CASE_SIGHTINGS) {
|
|
490
|
+
throw new Error(`A Case can retain at most ${MAX_CASE_SIGHTINGS} sightings. No existing sighting was removed.`);
|
|
491
|
+
}
|
|
1237
492
|
const item = record(raw);
|
|
1238
493
|
const created = normalizeCaseSighting({
|
|
1239
494
|
...item,
|
|
@@ -1247,312 +502,6 @@ export function appendCaseSighting(current, raw, now, validPinIds) {
|
|
|
1247
502
|
export function mergeCaseSightings(local, imported, fallback, validPinIds) {
|
|
1248
503
|
return normalizeCaseSightings([...local, ...imported], fallback, validPinIds);
|
|
1249
504
|
}
|
|
1250
|
-
function normalizeObservedEffectReview(raw, fallback, validPinIds, validSightingIds, options = {}) {
|
|
1251
|
-
const item = record(raw);
|
|
1252
|
-
if (typeof item.state !== 'string' || !OBSERVED_EFFECT_STATES.has(item.state))
|
|
1253
|
-
return null;
|
|
1254
|
-
if (typeof item.sourceClass !== 'string' || !OBSERVED_EFFECT_SOURCE_CLASSES.has(item.sourceClass))
|
|
1255
|
-
return null;
|
|
1256
|
-
const source = text(item.source, MAX_RESPONSE_LABEL_LENGTH);
|
|
1257
|
-
const observedAt = optionalIso(item.observedAt, options);
|
|
1258
|
-
if (!source || !observedAt)
|
|
1259
|
-
return null;
|
|
1260
|
-
const createdAt = iso(item.createdAt, observedAt || fallback, options);
|
|
1261
|
-
const evidencePinId = typeof item.evidencePinId === 'string'
|
|
1262
|
-
&& SAFE_ID_RE.test(item.evidencePinId)
|
|
1263
|
-
&& (!validPinIds || validPinIds.has(item.evidencePinId))
|
|
1264
|
-
? item.evidencePinId
|
|
1265
|
-
: null;
|
|
1266
|
-
const sightingId = typeof item.sightingId === 'string'
|
|
1267
|
-
&& SAFE_ID_RE.test(item.sightingId)
|
|
1268
|
-
&& (!validSightingIds || validSightingIds.has(item.sightingId))
|
|
1269
|
-
? item.sightingId
|
|
1270
|
-
: null;
|
|
1271
|
-
const linkLimitations = [
|
|
1272
|
-
...(item.evidencePinId != null && !evidencePinId ? ['A malformed or dangling evidence-pin reference was omitted from this independent review.'] : []),
|
|
1273
|
-
...(item.sightingId != null && !sightingId ? ['A malformed or dangling sighting reference was omitted from this independent review.'] : []),
|
|
1274
|
-
];
|
|
1275
|
-
return {
|
|
1276
|
-
id: safeId(item.id, 'effect-review', { state: item.state, source, observedAt, createdAt }),
|
|
1277
|
-
state: item.state,
|
|
1278
|
-
observedAt,
|
|
1279
|
-
sourceClass: item.sourceClass,
|
|
1280
|
-
source,
|
|
1281
|
-
completeness: typeof item.completeness === 'string' && COMPLETENESS.has(item.completeness)
|
|
1282
|
-
? item.completeness
|
|
1283
|
-
: 'unknown',
|
|
1284
|
-
limitations: limitations([...linkLimitations, ...limitations(item.limitations)]),
|
|
1285
|
-
evidencePinId,
|
|
1286
|
-
sightingId,
|
|
1287
|
-
followUpAt: optionalIso(item.followUpAt, options),
|
|
1288
|
-
createdAt,
|
|
1289
|
-
};
|
|
1290
|
-
}
|
|
1291
|
-
export function normalizeCaseObservedEffectHistory(raw, fallback, validPinIds, validSightingIds, options = {}) {
|
|
1292
|
-
const root = record(raw);
|
|
1293
|
-
const source = Array.isArray(raw) ? raw : Array.isArray(root.reviews) ? root.reviews : [];
|
|
1294
|
-
const byId = new Map();
|
|
1295
|
-
let invalid = 0;
|
|
1296
|
-
let duplicateConflict = 0;
|
|
1297
|
-
for (const item of source.slice(0, MAX_CASE_OBSERVED_EFFECT_REVIEWS * 4)) {
|
|
1298
|
-
const normalized = normalizeObservedEffectReview(item, fallback, validPinIds, validSightingIds, options);
|
|
1299
|
-
if (!normalized) {
|
|
1300
|
-
invalid += 1;
|
|
1301
|
-
continue;
|
|
1302
|
-
}
|
|
1303
|
-
const existing = byId.get(normalized.id);
|
|
1304
|
-
if (!existing)
|
|
1305
|
-
byId.set(normalized.id, normalized);
|
|
1306
|
-
else if (JSON.stringify(normalized) !== JSON.stringify(existing)) {
|
|
1307
|
-
duplicateConflict += 1;
|
|
1308
|
-
if (JSON.stringify(normalized) < JSON.stringify(existing))
|
|
1309
|
-
byId.set(normalized.id, normalized);
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
|
-
const all = [...byId.values()].sort((left, right) => Date.parse(left.observedAt) - Date.parse(right.observedAt) || compareCodeUnits(left.id, right.id));
|
|
1313
|
-
invalid += Math.max(0, source.length - MAX_CASE_OBSERVED_EFFECT_REVIEWS * 4);
|
|
1314
|
-
const omitted = boundedCounter(root.omitted)
|
|
1315
|
-
+ invalid
|
|
1316
|
-
+ duplicateConflict
|
|
1317
|
-
+ Math.max(0, all.length - MAX_CASE_OBSERVED_EFFECT_REVIEWS);
|
|
1318
|
-
const preV13HistoryUnavailable = options.sourceVersion != null && options.sourceVersion <= 12
|
|
1319
|
-
? true
|
|
1320
|
-
: root.preV13HistoryUnavailable === true;
|
|
1321
|
-
const retainedLimitations = limitations(root.limitations).filter((item) => !/^\d+ earlier observed-effect reviews? omitted by bounded retention\.$/u.test(item));
|
|
1322
|
-
return {
|
|
1323
|
-
reviews: all.slice(-MAX_CASE_OBSERVED_EFFECT_REVIEWS),
|
|
1324
|
-
omitted,
|
|
1325
|
-
preV13HistoryUnavailable,
|
|
1326
|
-
limitations: lifecycleLimitations([
|
|
1327
|
-
...(omitted ? [`${omitted} earlier observed-effect review${omitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
|
|
1328
|
-
...(invalid ? [`${invalid} malformed or unlinked observed-effect review${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
|
|
1329
|
-
...(duplicateConflict ? [`${duplicateConflict} conflicting observed-effect review identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
|
|
1330
|
-
...(preV13HistoryUnavailable ? ['Migrated from a pre-v13 Case; earlier independent observed-effect review history is unavailable.'] : []),
|
|
1331
|
-
'Observed-effect reviews are independent point-in-time records; provider workflow events do not create or replace them.',
|
|
1332
|
-
...retainedLimitations,
|
|
1333
|
-
]),
|
|
1334
|
-
};
|
|
1335
|
-
}
|
|
1336
|
-
export function appendCaseObservedEffectReview(current, raw, now, validPinIds, validSightingIds) {
|
|
1337
|
-
const item = record(raw);
|
|
1338
|
-
const created = normalizeObservedEffectReview({ ...item, id: freshId('effect-review'), createdAt: now }, now, validPinIds, validSightingIds);
|
|
1339
|
-
if (!created)
|
|
1340
|
-
throw new Error('An independent observed-effect review requires a valid state, time, source class, and source.');
|
|
1341
|
-
return normalizeCaseObservedEffectHistory({
|
|
1342
|
-
reviews: [...current.reviews, created],
|
|
1343
|
-
omitted: current.omitted,
|
|
1344
|
-
preV13HistoryUnavailable: current.preV13HistoryUnavailable,
|
|
1345
|
-
limitations: current.limitations,
|
|
1346
|
-
}, now, validPinIds, validSightingIds);
|
|
1347
|
-
}
|
|
1348
|
-
export function mergeCaseObservedEffectHistories(local, imported, fallback, validPinIds, validSightingIds) {
|
|
1349
|
-
return normalizeCaseObservedEffectHistory({
|
|
1350
|
-
reviews: [...local.reviews, ...imported.reviews],
|
|
1351
|
-
omitted: Math.max(local.omitted, imported.omitted),
|
|
1352
|
-
preV13HistoryUnavailable: local.preV13HistoryUnavailable || imported.preV13HistoryUnavailable,
|
|
1353
|
-
limitations: [...local.limitations, ...imported.limitations],
|
|
1354
|
-
}, fallback, validPinIds, validSightingIds);
|
|
1355
|
-
}
|
|
1356
|
-
function normalizeClosure(raw, fallback, validReviewIds, validActionIds, options = {}, linkContext = {}) {
|
|
1357
|
-
const item = record(raw);
|
|
1358
|
-
if (typeof item.reason !== 'string' || !CLOSURE_REASONS.has(item.reason))
|
|
1359
|
-
return null;
|
|
1360
|
-
const reason = item.reason;
|
|
1361
|
-
const summary = text(item.summary, MAX_RESPONSE_RATIONALE_LENGTH);
|
|
1362
|
-
if (!summary)
|
|
1363
|
-
return null;
|
|
1364
|
-
const createdAt = iso(item.createdAt, fallback, options);
|
|
1365
|
-
const observedEffectReviewId = typeof item.observedEffectReviewId === 'string'
|
|
1366
|
-
&& SAFE_ID_RE.test(item.observedEffectReviewId)
|
|
1367
|
-
&& (!validReviewIds || validReviewIds.has(item.observedEffectReviewId))
|
|
1368
|
-
? item.observedEffectReviewId
|
|
1369
|
-
: null;
|
|
1370
|
-
const actionId = typeof item.actionId === 'string'
|
|
1371
|
-
&& SAFE_ID_RE.test(item.actionId)
|
|
1372
|
-
&& (!validActionIds || validActionIds.has(item.actionId))
|
|
1373
|
-
? item.actionId
|
|
1374
|
-
: null;
|
|
1375
|
-
const linkedReview = observedEffectReviewId ? linkContext.reviewEvidence?.get(observedEffectReviewId) : undefined;
|
|
1376
|
-
const reviewPredatesClosure = linkedReview !== undefined
|
|
1377
|
-
&& Date.parse(linkedReview.observedAt) <= Date.parse(createdAt)
|
|
1378
|
-
&& Date.parse(linkedReview.createdAt) <= Date.parse(createdAt);
|
|
1379
|
-
if (linkContext.reviewEvidence && reason === 'independently_not_reproduced'
|
|
1380
|
-
&& (!reviewPredatesClosure || linkedReview?.state !== 'not_reproduced'))
|
|
1381
|
-
return null;
|
|
1382
|
-
if (linkContext.reviewEvidence && reason === 'infrastructure_changed'
|
|
1383
|
-
&& (!reviewPredatesClosure || linkedReview?.state !== 'changed'))
|
|
1384
|
-
return null;
|
|
1385
|
-
const linkedProviderEvents = actionId ? linkContext.providerResolutionEvents?.get(actionId) ?? [] : [];
|
|
1386
|
-
if (linkContext.providerResolutionEvents && reason === 'provider_reported_resolution_not_independently_checked'
|
|
1387
|
-
&& !linkedProviderEvents.some((event) => Date.parse(event.occurredAt) <= Date.parse(createdAt)))
|
|
1388
|
-
return null;
|
|
1389
|
-
const linkLimitations = [
|
|
1390
|
-
...(item.observedEffectReviewId != null && !observedEffectReviewId ? ['A malformed or dangling observed-effect review reference was omitted from this closure.'] : []),
|
|
1391
|
-
...(item.actionId != null && !actionId ? ['A malformed or dangling response-action reference was omitted from this closure.'] : []),
|
|
1392
|
-
];
|
|
1393
|
-
return {
|
|
1394
|
-
id: safeId(item.id, 'case-closure', { reason: item.reason, summary, createdAt }),
|
|
1395
|
-
reason,
|
|
1396
|
-
summary,
|
|
1397
|
-
observedEffectReviewId,
|
|
1398
|
-
actionId,
|
|
1399
|
-
limitations: limitations([...linkLimitations, ...limitations(item.limitations)]),
|
|
1400
|
-
createdAt,
|
|
1401
|
-
};
|
|
1402
|
-
}
|
|
1403
|
-
export function buildCaseClosureLinkContext(observedEffects, actions) {
|
|
1404
|
-
return {
|
|
1405
|
-
reviewEvidence: new Map(observedEffects.reviews.map((review) => [review.id, {
|
|
1406
|
-
state: review.state,
|
|
1407
|
-
observedAt: review.observedAt,
|
|
1408
|
-
createdAt: review.createdAt,
|
|
1409
|
-
}])),
|
|
1410
|
-
providerResolutionEvents: new Map(actions.map((action) => [action.id, action.history
|
|
1411
|
-
.filter((event) => event.applied && event.providerOutcome === 'provider_reports_resolved')
|
|
1412
|
-
.map((event) => ({ eventId: event.id, occurredAt: event.occurredAt }))])),
|
|
1413
|
-
};
|
|
1414
|
-
}
|
|
1415
|
-
export function normalizeCaseClosureHistory(raw, fallback, validReviewIds, validActionIds, options = {}, linkContext = {}) {
|
|
1416
|
-
const root = record(raw);
|
|
1417
|
-
const source = Array.isArray(raw) ? raw : Array.isArray(root.records) ? root.records : [];
|
|
1418
|
-
const byId = new Map();
|
|
1419
|
-
let invalid = 0;
|
|
1420
|
-
let duplicateConflict = 0;
|
|
1421
|
-
for (const item of source.slice(0, MAX_CASE_CLOSURES * 4)) {
|
|
1422
|
-
const normalized = normalizeClosure(item, fallback, validReviewIds, validActionIds, options, linkContext);
|
|
1423
|
-
if (!normalized) {
|
|
1424
|
-
invalid += 1;
|
|
1425
|
-
continue;
|
|
1426
|
-
}
|
|
1427
|
-
const existing = byId.get(normalized.id);
|
|
1428
|
-
if (!existing)
|
|
1429
|
-
byId.set(normalized.id, normalized);
|
|
1430
|
-
else if (JSON.stringify(normalized) !== JSON.stringify(existing)) {
|
|
1431
|
-
duplicateConflict += 1;
|
|
1432
|
-
if (JSON.stringify(normalized) < JSON.stringify(existing))
|
|
1433
|
-
byId.set(normalized.id, normalized);
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
const all = [...byId.values()].sort((left, right) => Date.parse(left.createdAt) - Date.parse(right.createdAt) || compareCodeUnits(left.id, right.id));
|
|
1437
|
-
invalid += Math.max(0, source.length - MAX_CASE_CLOSURES * 4);
|
|
1438
|
-
const omitted = boundedCounter(root.omitted)
|
|
1439
|
-
+ invalid
|
|
1440
|
-
+ duplicateConflict
|
|
1441
|
-
+ Math.max(0, all.length - MAX_CASE_CLOSURES);
|
|
1442
|
-
const preV13HistoryUnavailable = options.sourceVersion != null && options.sourceVersion <= 12
|
|
1443
|
-
? true
|
|
1444
|
-
: root.preV13HistoryUnavailable === true;
|
|
1445
|
-
const retainedLimitations = limitations(root.limitations).filter((item) => !/^\d+ earlier closure records? omitted by bounded retention\.$/u.test(item));
|
|
1446
|
-
return {
|
|
1447
|
-
records: all.slice(-MAX_CASE_CLOSURES),
|
|
1448
|
-
omitted,
|
|
1449
|
-
preV13HistoryUnavailable,
|
|
1450
|
-
limitations: lifecycleLimitations([
|
|
1451
|
-
...(omitted ? [`${omitted} earlier closure record${omitted === 1 ? '' : 's'} omitted by bounded retention.`] : []),
|
|
1452
|
-
...(invalid ? [`${invalid} malformed, unsupported, or unlinked closure record${invalid === 1 ? '' : 's'} omitted during normalisation.`] : []),
|
|
1453
|
-
...(duplicateConflict ? [`${duplicateConflict} conflicting closure record identit${duplicateConflict === 1 ? 'y was' : 'ies were'} reconciled deterministically.`] : []),
|
|
1454
|
-
...(preV13HistoryUnavailable ? ['Migrated from a pre-v13 Case; earlier deliberate closure history is unavailable.'] : []),
|
|
1455
|
-
'Closure records are deliberate analyst actions and do not establish absence, safety, provider performance, or legal sufficiency.',
|
|
1456
|
-
...retainedLimitations,
|
|
1457
|
-
]),
|
|
1458
|
-
};
|
|
1459
|
-
}
|
|
1460
|
-
export function appendCaseClosure(current, raw, now, observedEffects, actions) {
|
|
1461
|
-
const item = record(raw);
|
|
1462
|
-
const reason = typeof item.reason === 'string' && CLOSURE_REASONS.has(item.reason)
|
|
1463
|
-
? item.reason
|
|
1464
|
-
: null;
|
|
1465
|
-
const review = typeof item.observedEffectReviewId === 'string'
|
|
1466
|
-
? observedEffects.reviews.find((candidate) => candidate.id === item.observedEffectReviewId) ?? null
|
|
1467
|
-
: null;
|
|
1468
|
-
const action = typeof item.actionId === 'string'
|
|
1469
|
-
? actions.find((candidate) => candidate.id === item.actionId) ?? null
|
|
1470
|
-
: null;
|
|
1471
|
-
if (reason === 'independently_not_reproduced'
|
|
1472
|
-
&& (review?.state !== 'not_reproduced' || Date.parse(review.observedAt) > Date.parse(now) || Date.parse(review.createdAt) > Date.parse(now))) {
|
|
1473
|
-
throw new Error('This closure reason requires a linked independent not-reproduced review.');
|
|
1474
|
-
}
|
|
1475
|
-
if (reason === 'infrastructure_changed'
|
|
1476
|
-
&& (review?.state !== 'changed' || Date.parse(review.observedAt) > Date.parse(now) || Date.parse(review.createdAt) > Date.parse(now))) {
|
|
1477
|
-
throw new Error('This closure reason requires a linked independent changed review.');
|
|
1478
|
-
}
|
|
1479
|
-
if (reason === 'provider_reported_resolution_not_independently_checked'
|
|
1480
|
-
&& (action?.providerOutcome !== 'provider_reports_resolved'
|
|
1481
|
-
|| !action.history.some((event) => event.applied
|
|
1482
|
-
&& event.providerOutcome === 'provider_reports_resolved'
|
|
1483
|
-
&& Date.parse(event.occurredAt) <= Date.parse(now)))) {
|
|
1484
|
-
throw new Error('This closure reason requires a linked typed provider-reported-resolution outcome.');
|
|
1485
|
-
}
|
|
1486
|
-
const linkContext = buildCaseClosureLinkContext(observedEffects, actions);
|
|
1487
|
-
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);
|
|
1488
|
-
if (!created || !reason)
|
|
1489
|
-
throw new Error('A deliberate closure requires a typed reason and summary.');
|
|
1490
|
-
return normalizeCaseClosureHistory({
|
|
1491
|
-
records: [...current.records, created],
|
|
1492
|
-
omitted: current.omitted,
|
|
1493
|
-
preV13HistoryUnavailable: current.preV13HistoryUnavailable,
|
|
1494
|
-
limitations: current.limitations,
|
|
1495
|
-
}, now, new Set(observedEffects.reviews.map((candidate) => candidate.id)), new Set(actions.map((candidate) => candidate.id)), {}, linkContext);
|
|
1496
|
-
}
|
|
1497
|
-
export function mergeCaseClosureHistories(local, imported, fallback, validReviewIds, validActionIds, linkContext = {}) {
|
|
1498
|
-
return normalizeCaseClosureHistory({
|
|
1499
|
-
records: [...local.records, ...imported.records],
|
|
1500
|
-
omitted: Math.max(local.omitted, imported.omitted),
|
|
1501
|
-
preV13HistoryUnavailable: local.preV13HistoryUnavailable || imported.preV13HistoryUnavailable,
|
|
1502
|
-
limitations: [...local.limitations, ...imported.limitations],
|
|
1503
|
-
}, fallback, validReviewIds, validActionIds, {}, linkContext);
|
|
1504
|
-
}
|
|
1505
|
-
export function buildCaseResponseLifecycleSummary(input) {
|
|
1506
|
-
const providerEvents = (input.actions ?? []).flatMap((action) => action.history
|
|
1507
|
-
.filter((event) => event.providerOutcome)
|
|
1508
|
-
.map((event) => ({ action, event })));
|
|
1509
|
-
providerEvents.sort((left, right) => Date.parse(right.event.occurredAt) - Date.parse(left.event.occurredAt)
|
|
1510
|
-
|| compareCodeUnits(right.event.id, left.event.id));
|
|
1511
|
-
const providerTime = providerEvents[0]?.event.occurredAt ?? null;
|
|
1512
|
-
const providerEventsAtLatestTime = providerTime === null
|
|
1513
|
-
? []
|
|
1514
|
-
: providerEvents.filter(({ event }) => event.occurredAt === providerTime);
|
|
1515
|
-
const latestProvider = providerEventsAtLatestTime.length === 1 && providerEventsAtLatestTime[0].event.applied
|
|
1516
|
-
? providerEventsAtLatestTime[0]
|
|
1517
|
-
: null;
|
|
1518
|
-
const providerOutcomeState = providerEvents.length === 0
|
|
1519
|
-
? 'missing'
|
|
1520
|
-
: latestProvider ? 'available' : 'ambiguous';
|
|
1521
|
-
const reviews = [...(input.observedEffects?.reviews ?? [])]
|
|
1522
|
-
.sort((left, right) => Date.parse(right.observedAt) - Date.parse(left.observedAt) || compareCodeUnits(right.id, left.id));
|
|
1523
|
-
const latestObserved = reviews[0] ?? null;
|
|
1524
|
-
const changedReviews = reviews.filter((review) => review.state === 'changed');
|
|
1525
|
-
const changedTime = changedReviews[0]?.observedAt ?? null;
|
|
1526
|
-
const changedAtLatestTime = changedTime === null
|
|
1527
|
-
? []
|
|
1528
|
-
: changedReviews.filter((review) => review.observedAt === changedTime);
|
|
1529
|
-
const latestObservedChangeAt = changedAtLatestTime.length === 1 ? changedTime : null;
|
|
1530
|
-
const observedChangeState = changedReviews.length === 0
|
|
1531
|
-
? 'missing'
|
|
1532
|
-
: latestObservedChangeAt ? 'available' : 'ambiguous';
|
|
1533
|
-
const latestClosure = [...(input.closures?.records ?? [])]
|
|
1534
|
-
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt) || compareCodeUnits(right.id, left.id))[0] ?? null;
|
|
1535
|
-
return {
|
|
1536
|
-
providerOutcomeState,
|
|
1537
|
-
latestProviderOutcome: latestProvider ? {
|
|
1538
|
-
actionId: latestProvider.action.id,
|
|
1539
|
-
eventId: latestProvider.event.id,
|
|
1540
|
-
outcome: latestProvider.event.providerOutcome,
|
|
1541
|
-
occurredAt: latestProvider.event.occurredAt,
|
|
1542
|
-
reference: latestProvider.event.reference,
|
|
1543
|
-
} : null,
|
|
1544
|
-
observedChangeState,
|
|
1545
|
-
latestObservedEffect: latestObserved ? {
|
|
1546
|
-
reviewId: latestObserved.id,
|
|
1547
|
-
state: latestObserved.state,
|
|
1548
|
-
observedAt: latestObserved.observedAt,
|
|
1549
|
-
sourceClass: latestObserved.sourceClass,
|
|
1550
|
-
source: latestObserved.source,
|
|
1551
|
-
} : null,
|
|
1552
|
-
latestObservedChangeAt,
|
|
1553
|
-
latestClosure,
|
|
1554
|
-
};
|
|
1555
|
-
}
|
|
1556
505
|
export function buildCaseInvestigationTrail(input) {
|
|
1557
506
|
return [
|
|
1558
507
|
...(input.assertions ?? []).map((item) => ({
|
|
@@ -1587,15 +536,15 @@ export function buildCaseInvestigationTrail(input) {
|
|
|
1587
536
|
id: `sighting:${item.id}`,
|
|
1588
537
|
kind: 'sighting',
|
|
1589
538
|
label: `${item.state.replaceAll('_', ' ')} · ${item.category}`,
|
|
1590
|
-
detail: `${item.source} · ${item.completeness}`,
|
|
1591
|
-
createdAt: item.
|
|
539
|
+
detail: `${item.source} · ${item.completeness} · observed ${item.observedAt ?? 'time unavailable'}`,
|
|
540
|
+
createdAt: item.createdAt,
|
|
1592
541
|
})),
|
|
1593
542
|
...(input.observedEffects?.reviews ?? []).map((item) => ({
|
|
1594
543
|
id: `observed-effect:${item.id}`,
|
|
1595
544
|
kind: 'observed_effect',
|
|
1596
545
|
label: `independent effect · ${item.state.replaceAll('_', ' ')}`,
|
|
1597
|
-
detail: `${item.source} · ${item.completeness}`,
|
|
1598
|
-
createdAt: item.
|
|
546
|
+
detail: `${item.source} · ${item.completeness} · observed ${item.observedAt}`,
|
|
547
|
+
createdAt: item.createdAt,
|
|
1599
548
|
})),
|
|
1600
549
|
...(input.closures?.records ?? []).map((item) => ({
|
|
1601
550
|
id: `closure:${item.id}`,
|