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