@slicedearth/whoisleuth-cli 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/cli/arguments.mjs +21 -5
  2. package/cli/artifact-validation/case-response.mjs +771 -0
  3. package/cli/artifact-validation/investigation-capsule.mjs +426 -0
  4. package/cli/artifact-validation/lookup-evidence.mjs +718 -0
  5. package/cli/artifact-validation/signed-review.mjs +598 -0
  6. package/cli/artifact-validation/structure-primitives.mjs +105 -0
  7. package/cli/artifact-verify.mjs +1 -1
  8. package/cli/assurance-command-runner.mjs +324 -0
  9. package/cli/command-reference.mjs +1175 -843
  10. package/cli/completion.mjs +56 -9
  11. package/cli/discriminated-command-handlers.mjs +11 -0
  12. package/cli/doctor.mjs +2 -2
  13. package/cli/evidence-command-runner.mjs +41 -31
  14. package/cli/export-evidence.mjs +1 -1
  15. package/cli/fail-policy.mjs +33 -8
  16. package/cli/formatters/terminal-command-formats.mjs +437 -0
  17. package/cli/formatters/terminal-lookup.mjs +758 -0
  18. package/cli/formatters/terminal-metadata.mjs +58 -0
  19. package/cli/formatters/terminal-shared.mjs +83 -0
  20. package/cli/formatters/terminal.mjs +5 -1232
  21. package/cli/history-command-runner.mjs +132 -0
  22. package/cli/inline-command-families.mjs +45 -0
  23. package/cli/inline-command-runner.mjs +55 -0
  24. package/cli/investigation-plan.mjs +9 -2
  25. package/cli/investigation-run.mjs +114 -9
  26. package/cli/lookup-brief.mjs +141 -23
  27. package/cli/lookup-browser-view.mjs +289 -0
  28. package/cli/lookup-browser.mjs +3 -288
  29. package/cli/mail-review.mjs +2 -6
  30. package/cli/offline-artifact-validation.mjs +19 -0
  31. package/cli/review-command-runner.mjs +269 -0
  32. package/cli/runner.mjs +15 -972
  33. package/cli/saved-lookup.mjs +19 -2
  34. package/cli/support-command-runner.mjs +229 -0
  35. package/cli/workflow-command-runner.mjs +129 -0
  36. package/docs/cli-reference.md +16 -9
  37. package/docs/cli.md +16 -2
  38. package/lib/availability.mjs +12 -9
  39. package/lib/bounded-contract-normalizers.mjs +2 -9
  40. package/lib/browser-library-profile.mjs +1 -2
  41. package/lib/client-behavior-profile.mjs +1 -2
  42. package/lib/credential-surface-profile.mjs +1 -1
  43. package/lib/domain-posture-analysis.mjs +10 -1
  44. package/lib/domain-posture.mjs +51 -23
  45. package/lib/evidence-export.mjs +47 -5
  46. package/lib/evidence-report-markdown.mjs +4 -0
  47. package/lib/evidence-report.mjs +50 -0
  48. package/lib/generated/cisa-kev-catalog.mjs +34 -4
  49. package/lib/generated/registrar-standing-catalogue.mjs +367 -0
  50. package/lib/json-record.mjs +12 -0
  51. package/lib/lookup-child-profile-contract.mjs +858 -0
  52. package/lib/lookup-contract-primitives.mjs +98 -0
  53. package/lib/lookup-diagnostics.mjs +96 -0
  54. package/lib/lookup-network-evidence-bounds.mjs +1 -0
  55. package/lib/lookup-response.mjs +367 -0
  56. package/lib/lookup.mjs +28 -443
  57. package/lib/page-role-profile.mjs +1 -3
  58. package/lib/registrar-standing-catalogue-contract.mjs +70 -0
  59. package/lib/registrar-standing-contract.mjs +300 -0
  60. package/lib/registrar-standing.mjs +269 -0
  61. package/lib/registry-insights.mjs +2 -8
  62. package/lib/risk-scoring.mjs +26 -10
  63. package/lib/sslbl-certificates.generated.mjs +138 -136
  64. package/lib/structured-data-identity.mjs +1 -3
  65. package/lib/technology-evidence-role.mjs +51 -0
  66. package/lib/threat-intelligence-runtime.mjs +3 -3
  67. package/lib/threat-intelligence-types.mjs +13 -0
  68. package/lib/threatfox-intelligence.mjs +1 -3
  69. package/lib/tls-intelligence.mjs +1 -2
  70. package/lib/urlhaus-intelligence.mjs +1 -3
  71. package/lib/urlscan-intelligence.mjs +1 -3
  72. package/lib/website-security-posture.mjs +1 -2
  73. package/lib/website-technology.mjs +72 -23
  74. package/lib/whois-common-formats.mjs +276 -0
  75. package/lib/whois-dialects.mjs +534 -0
  76. package/lib/whois-parser-support.mjs +305 -0
  77. package/lib/whois-parser.mjs +135 -0
  78. package/lib/whois.mjs +6 -1187
  79. package/package.json +2 -2
  80. package/packages/cases/case-evidence-model.mjs +9 -0
  81. package/packages/cases/case-migration-model.mjs +17 -11
  82. package/packages/cases/case-record-operations.mjs +235 -0
  83. package/packages/cases/case-response-model.mjs +13 -9
  84. package/packages/cases/case-response-packet.mjs +297 -112
  85. package/packages/comparison/page-similarity.mjs +23 -0
  86. package/packages/contracts/case-portability.mjs +235 -61
  87. package/packages/contracts/cli-lookup.mjs +4 -3
  88. package/packages/contracts/domain-control-manifest.mjs +1 -1
  89. package/packages/contracts/investigation-portability.mjs +1 -1
  90. package/packages/contracts/risk-calibration.mjs +2 -2
  91. package/packages/contracts/workspace-portability.mjs +34 -6
  92. package/packages/interchange/external-findings-import.mjs +113 -45
  93. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  94. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  95. package/packages/workspace/website-snapshot-model.mjs +79 -6
  96. package/third-party-notices.txt +1 -1
  97. package/cli/artifact-structure.mjs +0 -2507
package/package.json CHANGED
@@ -33,14 +33,14 @@
33
33
  "bugs": {
34
34
  "url": "https://github.com/slicedearth/whoisleuth/issues"
35
35
  },
36
- "version": "2.0.1",
36
+ "version": "2.2.0",
37
37
  "dependencies": {
38
38
  "@peculiar/x509": "2.0.0",
39
39
  "maxmind": "5.0.7",
40
40
  "parse5": "8.0.1",
41
41
  "reflect-metadata": "0.2.2",
42
42
  "tldts": "7.4.11",
43
- "undici": "8.10.0"
43
+ "undici": "8.10.1"
44
44
  },
45
45
  "files": [
46
46
  "bin/**/*.mjs",
@@ -413,6 +413,15 @@ export function latestCaseEvidence(record) {
413
413
  const history = record && Array.isArray(record.evidenceHistory) ? record.evidenceHistory : [];
414
414
  return history.at(-1) ?? null;
415
415
  }
416
+ /**
417
+ * Returns the exact hostname from the latest retained observation when it is
418
+ * available. The registrable Case identity is the conservative fallback; an
419
+ * older hostname is never substituted for a newer observation with unknown
420
+ * hostname context.
421
+ */
422
+ export function caseLookupTarget(record) {
423
+ return latestCaseEvidence(record)?.inputHostname || record.domain;
424
+ }
416
425
  // ---------------------------------------------------------------------------
417
426
  // Material-change comparison (pure; no Svelte, DOM, or persistence access)
418
427
  // ---------------------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  import { CASE_DISPOSITIONS, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, MAX_CASES, MAX_NOTES_PER_CASE, deterministicId, caseTimestampOrNull, normalizeCase, normalizeDomain, normalizeEvidenceHistory, normalizeNotes, normalizeReviewReasonCode, normalizeTags, objectRecord, safeId, } from "./case-record-model.mjs";
2
- import { CASE_REPORT_SCHEMA, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
2
+ import { CASE_REPORT_SCHEMA, PUBLIC_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, } from "../contracts/case-portability.mjs";
3
3
  import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
4
4
  import { assertBoundedJsonStructure } from "../../lib/bounded-json.mjs";
5
5
  import { inspectCaseBrandProfileIds, unionCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
@@ -51,13 +51,13 @@ export function normalizeCaseStore(raw) {
51
51
  throw new TypeError(`Unversioned Case stores are retired. Export or reset them explicitly before using Case schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
52
52
  }
53
53
  if (!CASE_IMPORT_VERSIONS.includes(sourceVersion) && sourceVersion < CASE_SCHEMA_VERSION) {
54
- throw new TypeError(`Case schema ${sourceVersion} is not part of the public compatibility boundary. Only public schema 12 migrates to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
54
+ throw new TypeError(`Case schema ${sourceVersion} is not part of the supported compatibility boundary. Exact published schemas 12 and 13 migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
55
55
  }
56
56
  if (sourceVersion > CASE_SCHEMA_VERSION) {
57
57
  throw new TypeError(`Case schema ${sourceVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
58
58
  }
59
59
  if (sourceVersion === CASE_SCHEMA_VERSION) {
60
- assertCurrentEvidenceHostnameShape(raw);
60
+ assertCurrentCaseShape(raw);
61
61
  }
62
62
  }
63
63
  const byDomain = new Map();
@@ -76,17 +76,23 @@ export function normalizeCaseStore(raw) {
76
76
  assignUniqueIds(cases);
77
77
  return { version: CASE_SCHEMA_VERSION, cases };
78
78
  }
79
- function assertCurrentEvidenceHostnameShape(raw) {
79
+ function assertCurrentCaseShape(raw) {
80
80
  for (const item of boundedCaseList(raw).items) {
81
- const evidenceHistory = objectRecord(item).evidenceHistory;
82
- if (!Array.isArray(evidenceHistory))
83
- continue;
84
- for (const snapshot of evidenceHistory) {
81
+ const itemRecord = objectRecord(item);
82
+ const evidenceHistory = itemRecord.evidenceHistory;
83
+ for (const snapshot of Array.isArray(evidenceHistory) ? evidenceHistory : []) {
85
84
  const record = objectRecord(snapshot);
86
85
  if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'inputHostname')) {
87
86
  throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} evidence snapshots must declare their exact submitted hostname, including null; unreleased local Case checkpoints are not interpreted as the v2 format and no data was changed.`);
88
87
  }
89
88
  }
89
+ const actions = itemRecord.actions;
90
+ for (const action of Array.isArray(actions) ? actions : []) {
91
+ const record = objectRecord(action);
92
+ if (Object.keys(record).length > 0 && !Object.hasOwn(record, 'routeObservedAt')) {
93
+ throw new TypeError(`Case schema ${CASE_SCHEMA_VERSION} response actions must declare their route observation time, including null; unreleased local Case checkpoints are not interpreted as the current format and no data was changed.`);
94
+ }
95
+ }
90
96
  }
91
97
  }
92
98
  /**
@@ -388,7 +394,7 @@ function caseCollectionImportEnvelope(importedRaw) {
388
394
  }
389
395
  for (const reportValue of reports) {
390
396
  const report = objectRecord(reportValue);
391
- assertOnlyEnvelopeKeys(report, rootVersion === CASE_SCHEMA_VERSION ? CLI_CASE_PACK_REPORT_KEYS : CLI_CASE_PACK_PUBLIC_REPORT_KEYS, 'CLI Case-pack report');
397
+ assertOnlyEnvelopeKeys(report, rootVersion === PUBLIC_CASE_SCHEMA_VERSION ? CLI_CASE_PACK_PUBLIC_REPORT_KEYS : CLI_CASE_PACK_REPORT_KEYS, 'CLI Case-pack report');
392
398
  if (report.schema !== CASE_REPORT_SCHEMA
393
399
  || !caseReportVersionMatchesCase(rootVersion, report.schemaVersion)
394
400
  || report.generatedAt !== root.exportedAt) {
@@ -420,7 +426,7 @@ export function mergeCases(localCases, importedRaw) {
420
426
  const importedVersion = parseStoreVersion(importedEnvelope);
421
427
  if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion < CASE_SCHEMA_VERSION
422
428
  && !CASE_IMPORT_VERSIONS.includes(importedVersion)) {
423
- throw new Error(`Case schema ${importedVersion} is not part of the public compatibility boundary. Only public schema 12 migrates to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
429
+ throw new Error(`Case schema ${importedVersion} is not part of the supported compatibility boundary. Exact published schemas 12 and 13 migrate to schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
424
430
  }
425
431
  if (importedVersion !== null && Number.isSafeInteger(importedVersion) && importedVersion > CASE_SCHEMA_VERSION) {
426
432
  throw new Error(`Case schema ${importedVersion} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
@@ -430,7 +436,7 @@ export function mergeCases(localCases, importedRaw) {
430
436
  throw new Error(`Expected a well-formed WHOISleuth Case export using schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
431
437
  }
432
438
  if (importedVersion === CASE_SCHEMA_VERSION)
433
- assertCurrentEvidenceHostnameShape(importedEnvelope);
439
+ assertCurrentCaseShape(importedEnvelope);
434
440
  const local = normalizeCaseStore(localCases).cases;
435
441
  const supportedImportedVersion = importedVersion ?? 0;
436
442
  const byDomain = new Map(local.map((item) => [item.domain, item]));
@@ -1,11 +1,101 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
+ import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
3
4
  import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseObservedEffectReview, appendCaseSighting, buildCaseClosureLinkContext, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.mjs";
4
5
  import { CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
5
6
  import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, caseTimestampOrNull, deterministicId, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.mjs";
6
7
  import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
7
8
  import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
8
9
  import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
10
+ export const MAX_CASE_OBJECTIVE_LENGTH = 320;
11
+ export const MAX_CASE_INCIDENT_URL_LENGTH = 1_850;
12
+ export const INCIDENT_CONTEXT_STATEMENT_PREFIX = 'Investigate incident URL: ';
13
+ const OBJECTIVE_PREFIX = 'Objective: ';
14
+ const RETENTION_SEPARATOR = ' | URL retained: ';
15
+ function boundedContextText(value, maximum) {
16
+ return typeof value === 'string'
17
+ ? value.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
18
+ : '';
19
+ }
20
+ export function normalizeCaseObjective(value) {
21
+ return boundedContextText(value, MAX_CASE_OBJECTIVE_LENGTH);
22
+ }
23
+ export function parseIncidentUrlContext(value) {
24
+ if (typeof value !== 'string' || !value || value.length > MAX_CASE_INCIDENT_URL_LENGTH)
25
+ return null;
26
+ if (/[\u0000-\u001f\u007f]/u.test(value) || value.trim() !== value)
27
+ return null;
28
+ let parsed;
29
+ try {
30
+ parsed = new URL(value);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !parsed.hostname)
36
+ return null;
37
+ const hostname = parsed.hostname.toLowerCase().replace(/\.$/u, '');
38
+ const registrableDomain = canonicalRegistrableDomain(hostname);
39
+ if (!registrableDomain)
40
+ return null;
41
+ const exactUrl = parsed.toString();
42
+ if (exactUrl.length > MAX_CASE_INCIDENT_URL_LENGTH)
43
+ return null;
44
+ return Object.freeze({
45
+ exactUrl,
46
+ hostname,
47
+ registrableDomain,
48
+ originUrl: parsed.origin,
49
+ hasPath: parsed.pathname !== '/',
50
+ hasQuery: Boolean(parsed.search),
51
+ hasFragment: Boolean(parsed.hash),
52
+ });
53
+ }
54
+ export function caseInvestigationContext(record) {
55
+ if (!record)
56
+ return null;
57
+ for (const assertion of [...record.assertions].reverse()) {
58
+ if (assertion.kind !== 'next_step' || assertion.state !== 'open'
59
+ || !assertion.statement.startsWith(INCIDENT_CONTEXT_STATEMENT_PREFIX))
60
+ continue;
61
+ const incidentUrl = assertion.statement.slice(INCIDENT_CONTEXT_STATEMENT_PREFIX.length);
62
+ const parsed = parseIncidentUrlContext(incidentUrl);
63
+ if (!parsed || parsed.registrableDomain !== record.domain)
64
+ continue;
65
+ const rationale = assertion.rationale ?? '';
66
+ const separatorIndex = rationale.lastIndexOf(RETENTION_SEPARATOR);
67
+ const objective = separatorIndex > OBJECTIVE_PREFIX.length && rationale.startsWith(OBJECTIVE_PREFIX)
68
+ ? normalizeCaseObjective(rationale.slice(OBJECTIVE_PREFIX.length, separatorIndex))
69
+ : '';
70
+ const retention = separatorIndex >= 0 ? rationale.slice(separatorIndex + RETENTION_SEPARATOR.length) : '';
71
+ if (!objective || (retention !== 'exact' && retention !== 'origin_only'))
72
+ continue;
73
+ return Object.freeze({
74
+ objective,
75
+ incidentUrl: parsed.exactUrl,
76
+ urlRetention: retention,
77
+ assertionId: assertion.id,
78
+ updatedAt: assertion.updatedAt,
79
+ });
80
+ }
81
+ return null;
82
+ }
83
+ export function caseInvestigationContextAssertion(input) {
84
+ const objective = normalizeCaseObjective(input.objective);
85
+ if (!objective)
86
+ throw new Error('Enter the investigation objective before retaining Incident context.');
87
+ const parsed = parseIncidentUrlContext(input.incidentUrl);
88
+ if (!parsed)
89
+ throw new Error(`Enter one absolute HTTP(S) Incident URL of at most ${MAX_CASE_INCIDENT_URL_LENGTH} characters without credentials.`);
90
+ const retention = input.retainExactUrl ? 'exact' : 'origin_only';
91
+ const retainedUrl = input.retainExactUrl ? parsed.exactUrl : parsed.originUrl;
92
+ return Object.freeze({
93
+ statement: `${INCIDENT_CONTEXT_STATEMENT_PREFIX}${retainedUrl}`,
94
+ rationale: `${OBJECTIVE_PREFIX}${objective}${RETENTION_SEPARATOR}${retention}`,
95
+ retainedUrl,
96
+ retention,
97
+ });
98
+ }
9
99
  // ---------------------------------------------------------------------------
10
100
  // Case normalization
11
101
  // ---------------------------------------------------------------------------
@@ -300,3 +390,148 @@ export function updateCase(cases, id, patch, nowIso) {
300
390
  next[index] = record;
301
391
  return { cases: next, record };
302
392
  }
393
+ /**
394
+ * Records one reviewed conclusion as a single pure Case mutation. The selected
395
+ * observations become bounded Case pins before the decision is created, so the
396
+ * decision cannot point at absent evidence. Counterevidence is retained as a
397
+ * separate resolved contradiction assertion rather than being hidden inside a
398
+ * favourable disposition.
399
+ */
400
+ export function recordCaseConclusion(cases, id, input, nowIso) {
401
+ const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
402
+ const disposition = normalizeDisposition(input.disposition);
403
+ const reviewReasonCode = normalizeReviewReasonCode(input.reviewReasonCode);
404
+ if (disposition === 'unreviewed') {
405
+ throw new Error('Select a reviewed disposition before recording a conclusion.');
406
+ }
407
+ if (!reviewReasonCode) {
408
+ throw new Error('Select the reviewed reason before recording a conclusion.');
409
+ }
410
+ if (!Array.isArray(input.evidence) || !input.evidence.length) {
411
+ throw new Error('Select at least one observed fact for this conclusion.');
412
+ }
413
+ const invalidStance = input.evidence.find((item) => (!item || !['supports', 'contradicts', 'unresolved'].includes(item.stance)));
414
+ if (invalidStance)
415
+ throw new Error('A conclusion evidence relationship is invalid.');
416
+ if (!input.evidence.some((item) => item.stance === 'supports')) {
417
+ throw new Error('A conclusion requires at least one observed fact that supports it. Record only contradictory or unresolved material as an assertion instead.');
418
+ }
419
+ const current = cases.find((item) => item.id === id);
420
+ if (!current)
421
+ throw new Error('That case no longer exists.');
422
+ const existingPinIds = new Set(current.evidencePins.map((pin) => pin.id));
423
+ const withPins = updateCase(cases, id, {
424
+ evidencePins: input.evidence.map((item) => item.pin),
425
+ }, now);
426
+ const addedPins = withPins.record.evidencePins.filter((pin) => !existingPinIds.has(pin.id));
427
+ if (addedPins.length !== input.evidence.length) {
428
+ throw new Error('The selected conclusion evidence could not be retained completely.');
429
+ }
430
+ const supportingPinIds = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'supports' ? [pin.id] : []));
431
+ let concluded = updateCase(withPins.cases, id, {
432
+ disposition,
433
+ reviewReasonCode,
434
+ decision: {
435
+ summary: input.summary,
436
+ rationale: input.rationale,
437
+ evidencePinIds: supportingPinIds,
438
+ },
439
+ }, now);
440
+ const contradictionRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'contradicts'
441
+ ? [{ evidencePinId: pin.id, stance: 'contradicts' }]
442
+ : []));
443
+ if (contradictionRelations.length) {
444
+ const retainedSummary = concluded.record.decisions.at(-1)?.summary ?? 'Analyst conclusion';
445
+ concluded = updateCase(concluded.cases, id, {
446
+ assertion: {
447
+ kind: 'contradiction',
448
+ statement: `Counterevidence considered for: ${retainedSummary}`,
449
+ rationale: input.rationale,
450
+ evidenceRelations: contradictionRelations,
451
+ state: 'resolved',
452
+ },
453
+ }, now);
454
+ }
455
+ const unresolvedRelations = addedPins.flatMap((pin, index) => (input.evidence[index]?.stance === 'unresolved'
456
+ ? [{ evidencePinId: pin.id, stance: 'unresolved' }]
457
+ : []));
458
+ if (unresolvedRelations.length) {
459
+ const retainedSummary = concluded.record.decisions.at(-1)?.summary ?? 'Analyst conclusion';
460
+ concluded = updateCase(concluded.cases, id, {
461
+ assertion: {
462
+ kind: 'unknown',
463
+ statement: `Unresolved evidence considered for: ${retainedSummary}`,
464
+ rationale: input.rationale,
465
+ evidenceRelations: unresolvedRelations,
466
+ state: 'open',
467
+ },
468
+ }, now);
469
+ }
470
+ return concluded;
471
+ }
472
+ export function recordCaseInvestigationContext(cases, id, input, nowIso) {
473
+ const current = cases.find((item) => item.id === id);
474
+ if (!current)
475
+ throw new Error('That case no longer exists.');
476
+ const parsed = parseIncidentUrlContext(input.incidentUrl);
477
+ if (!parsed || parsed.registrableDomain !== current.domain) {
478
+ throw new Error(`The Incident URL must belong to the Case domain ${current.domain}.`);
479
+ }
480
+ const context = caseInvestigationContextAssertion(input);
481
+ const existing = caseInvestigationContext(current);
482
+ return updateCase(cases, id, existing
483
+ ? {
484
+ assertionUpdate: {
485
+ id: existing.assertionId,
486
+ statement: context.statement,
487
+ rationale: context.rationale,
488
+ state: 'open',
489
+ },
490
+ }
491
+ : {
492
+ assertion: {
493
+ kind: 'next_step',
494
+ statement: context.statement,
495
+ rationale: context.rationale,
496
+ evidenceRelations: [],
497
+ state: 'open',
498
+ },
499
+ }, nowIso);
500
+ }
501
+ export function recordCaseRecheckOutcome(cases, id, input, nowIso) {
502
+ const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
503
+ const current = cases.find((item) => item.id === id);
504
+ if (!current)
505
+ throw new Error('That case no longer exists.');
506
+ const beforePinIds = new Set(current.evidencePins.map((pin) => pin.id));
507
+ const withPin = updateCase(cases, id, {
508
+ evidencePin: {
509
+ field: 'case.recheck_comparison',
510
+ category: 'recheck',
511
+ label: 'Recheck comparison',
512
+ value: input.comparisonSummary,
513
+ source: input.source,
514
+ sourceState: 'reviewed',
515
+ observedAt: input.observedAt,
516
+ collectionDepth: input.collectionDepth,
517
+ completeness: input.completeness,
518
+ truncated: false,
519
+ limitations: input.limitations,
520
+ },
521
+ }, now);
522
+ const comparisonPin = withPin.record.evidencePins.find((pin) => !beforePinIds.has(pin.id));
523
+ if (!comparisonPin)
524
+ throw new Error('The recheck comparison could not be retained.');
525
+ return updateCase(withPin.cases, id, {
526
+ observedEffectReview: {
527
+ state: input.state,
528
+ observedAt: input.observedAt,
529
+ sourceClass: 'analyst',
530
+ source: input.source,
531
+ completeness: input.completeness,
532
+ evidencePinId: comparisonPin.id,
533
+ followUpAt: input.followUpAt,
534
+ limitations: input.limitations,
535
+ },
536
+ }, now);
537
+ }
@@ -2,7 +2,7 @@
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 { 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";
5
+ import { 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, CASE_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
6
6
  import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
7
7
  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, };
8
8
  export const CASE_EVIDENCE_RELATION_STANCES = ['supports', 'contradicts', 'unresolved'];
@@ -118,8 +118,8 @@ const SIGHTING_CATEGORIES = new Set(CASE_SIGHTING_CATEGORIES);
118
118
  const SHA256_RE = /^[a-f0-9]{64}$/u;
119
119
  function currentActionNormalizationOptions(validEvidencePinIds) {
120
120
  return validEvidencePinIds
121
- ? { sourceVersion: 13, validEvidencePinIds }
122
- : { sourceVersion: 13 };
121
+ ? { sourceVersion: CASE_SCHEMA_VERSION, validEvidencePinIds }
122
+ : { sourceVersion: CASE_SCHEMA_VERSION };
123
123
  }
124
124
  function record(value) {
125
125
  return value && typeof value === 'object' && !Array.isArray(value)
@@ -668,6 +668,9 @@ function normalizeAction(raw, fallback, options = {}) {
668
668
  : 'internal_review',
669
669
  recipient,
670
670
  contactSource: text(item.contactSource, MAX_RESPONSE_LABEL_LENGTH) || 'analyst_supplied',
671
+ routeObservedAt: options.sourceVersion != null && options.sourceVersion < 14
672
+ ? null
673
+ : optionalIso(item.routeObservedAt, options),
671
674
  contactLimitations: limitations(item.contactLimitations),
672
675
  dueAt: optionalIso(item.dueAt, options),
673
676
  state: history.state,
@@ -699,7 +702,7 @@ function mergeNormalizedActions(left, right, fallback) {
699
702
  history: [...left.history, ...right.history],
700
703
  historyOmitted: Math.max(left.historyOmitted, right.historyOmitted),
701
704
  historyLimitations: [...left.historyLimitations, ...right.historyLimitations],
702
- }, fallback, { sourceVersion: 13 });
705
+ }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
703
706
  }
704
707
  function boundActionCollection(actions, fallback) {
705
708
  const keep = new Set();
@@ -719,7 +722,7 @@ function boundActionCollection(actions, fallback) {
719
722
  let bounded = actions.map((action) => {
720
723
  const history = action.history.filter((event) => keep.has(`${action.id}\u0000${event.id}`));
721
724
  const omitted = action.historyOmitted + action.history.length - history.length;
722
- return normalizeAction({ ...action, history, historyOmitted: omitted }, fallback, { sourceVersion: 13 });
725
+ return normalizeAction({ ...action, history, historyOmitted: omitted }, fallback, { sourceVersion: CASE_SCHEMA_VERSION });
723
726
  });
724
727
  while (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE) {
725
728
  const candidate = bounded
@@ -734,7 +737,7 @@ function boundActionCollection(actions, fallback) {
734
737
  ...action,
735
738
  history: action.history.filter((event) => event.id !== candidate.event.id),
736
739
  historyOmitted: action.historyOmitted + 1,
737
- }, fallback, { sourceVersion: 13 }));
740
+ }, fallback, { sourceVersion: CASE_SCHEMA_VERSION }));
738
741
  }
739
742
  if (bytes(bounded) > MAX_CASE_ACTION_HISTORY_BYTES_PER_CASE && bounded.length > 1) {
740
743
  const byteOmissionPattern = /^(\d+) earlier response actions? omitted by the per-Case response-history byte bound\.$/u;
@@ -859,10 +862,10 @@ export function appendCaseAction(current, raw, now) {
859
862
  historyOmitted: 0,
860
863
  createdAt: now,
861
864
  metadataUpdatedAt: now,
862
- }, now, { sourceVersion: 13 });
865
+ }, now, { sourceVersion: CASE_SCHEMA_VERSION });
863
866
  if (!created)
864
867
  throw new Error('An action requires a recipient or internal owner.');
865
- return normalizeCaseActions([...current, created], now, { sourceVersion: 13 });
868
+ return normalizeCaseActions([...current, created], now, { sourceVersion: CASE_SCHEMA_VERSION });
866
869
  }
867
870
  export function appendCaseActionTransition(current, actionId, raw, now, validPinIds) {
868
871
  const action = current.find((item) => item.id === actionId);
@@ -927,6 +930,7 @@ export function updateCaseAction(current, raw, now, validPinIds) {
927
930
  type: Object.hasOwn(patch, 'type') ? patch.type : existing.type,
928
931
  recipient: Object.hasOwn(patch, 'recipient') ? patch.recipient : existing.recipient,
929
932
  contactSource: Object.hasOwn(patch, 'contactSource') ? patch.contactSource : existing.contactSource,
933
+ routeObservedAt: Object.hasOwn(patch, 'routeObservedAt') ? patch.routeObservedAt : existing.routeObservedAt,
930
934
  contactLimitations: Object.hasOwn(patch, 'contactLimitations') ? patch.contactLimitations : existing.contactLimitations,
931
935
  dueAt: Object.hasOwn(patch, 'dueAt') ? patch.dueAt : existing.dueAt,
932
936
  followUpAt: Object.hasOwn(patch, 'followUpAt') ? patch.followUpAt : existing.followUpAt,
@@ -940,7 +944,7 @@ export function updateCaseAction(current, raw, now, validPinIds) {
940
944
  let updated = normalizeAction(metadata, now, currentActionNormalizationOptions(validPinIds));
941
945
  if (!updated)
942
946
  throw new Error('An action requires a recipient or internal owner.');
943
- const materialChanged = ['type', 'recipient', 'contactSource', 'contactLimitations', 'originActionId']
947
+ const materialChanged = ['type', 'recipient', 'contactSource', 'routeObservedAt', 'contactLimitations', 'originActionId']
944
948
  .some((key) => Object.hasOwn(patch, key) && JSON.stringify(record(existing)[key]) !== JSON.stringify(record(updated)[key]));
945
949
  if (materialChanged && ['submitted', 'acknowledged', 'terminal'].includes(existing.state)) {
946
950
  throw new Error('Submitted or terminal action identity and recipient metadata cannot be rewritten; create a linked follow-on action instead.');