@slicedearth/whoisleuth-cli 2.0.1 → 2.1.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 (95) 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 +4 -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 +46 -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-response-model.mjs +13 -9
  83. package/packages/cases/case-response-packet.mjs +297 -112
  84. package/packages/comparison/page-similarity.mjs +23 -0
  85. package/packages/contracts/case-portability.mjs +225 -61
  86. package/packages/contracts/cli-lookup.mjs +4 -3
  87. package/packages/contracts/domain-control-manifest.mjs +1 -1
  88. package/packages/contracts/investigation-portability.mjs +1 -1
  89. package/packages/contracts/risk-calibration.mjs +2 -2
  90. package/packages/contracts/workspace-portability.mjs +34 -6
  91. package/packages/investigation/evidence-coverage-ledger.mjs +21 -0
  92. package/packages/investigation/lookup-claim-readiness.mjs +2 -2
  93. package/packages/workspace/website-snapshot-model.mjs +79 -6
  94. package/third-party-notices.txt +1 -1
  95. 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.1.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]));
@@ -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.');