pulse-updates 1.3.7 → 1.3.8

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/src/links.ts CHANGED
@@ -134,6 +134,51 @@ export type AnonymousFirstOpenResult =
134
134
  | 'retry_scheduled'
135
135
  | 'terminal_error';
136
136
 
137
+ export type AnonymousFirstOpenTerminalStatus = 'FOUND' | 'NOT_FOUND' | 'FAILURE';
138
+
139
+ /** Explicit receiver decision for one terminal-delivery attempt. */
140
+ export type AnonymousFirstOpenTerminalDisposition = 'accepted' | 'retry' | 'drop';
141
+
142
+ export type AnonymousFirstOpenTerminalReason =
143
+ | 'matched'
144
+ | 'unmatched'
145
+ | 'ambiguous'
146
+ | 'holdout'
147
+ | 'low_confidence'
148
+ | 'shadow_would_route'
149
+ | 'shadow_attributed'
150
+ | 'analytics_attributed'
151
+ | 'target_revoked'
152
+ | 'disabled'
153
+ | 'expired_replay'
154
+ | 'invalid_request'
155
+ | 'invalid_response'
156
+ | 'policy_rejected';
157
+
158
+ /**
159
+ * Sanitized terminal result of the anonymous first-open rail. It deliberately carries no
160
+ * install-attempt id, token, device signal or raw server error. A 204 is always `no_route` and
161
+ * can never make the client navigate, including when Encore reports a shadow observation.
162
+ */
163
+ export interface AnonymousFirstOpenTerminalOutcome {
164
+ /** Stable UUIDv4 for receiver-side deduplication of at-least-once delivery. */
165
+ eventId: string;
166
+ status: AnonymousFirstOpenTerminalStatus;
167
+ rail: 'fast_route' | 'no_route';
168
+ routed: boolean;
169
+ retryable: false;
170
+ reason: AnonymousFirstOpenTerminalReason;
171
+ occurredAt: string;
172
+ /** Present only for FOUND and copied from the already-normalized resolver result. */
173
+ matchBasis?: DeferredLinkMatchBasis;
174
+ /** Present only for FOUND, finite and clamped to [0, 1]. */
175
+ confidence?: number;
176
+ /** Optional FOUND-only campaign dimensions; strings are trimmed and bounded to 128 chars. */
177
+ campaignId?: string | null;
178
+ experimentId?: string | null;
179
+ variantId?: string | null;
180
+ }
181
+
137
182
  export type AndroidInstallReferrerStatus =
138
183
  | 'OK'
139
184
  | 'NO_TOKEN'
@@ -163,7 +208,13 @@ export interface DeferredLinkClientOptions<Action extends string = DeferredLinkA
163
208
  allowedActions?: readonly Action[];
164
209
  /** Actions that require both a deterministic match and a stable signed-in account. */
165
210
  sensitiveActions?: readonly Action[];
211
+ /**
212
+ * Routing callbacks are irreversible commit boundaries: return `false` when no navigation
213
+ * happened. If a newer accepted capture arrives while a successful callback is pending, Pulse
214
+ * persists it but will not auto-route a second journey in the same client lifecycle.
215
+ */
166
216
  onDeepLink: (deepLink: string, link: ResolvedDeferredLink<Action>) => boolean | void | Promise<boolean | void>;
217
+ /** Same commit-boundary contract as `onDeepLink`. */
167
218
  onAction?: (link: ResolvedDeferredLink<Action>) => boolean | void | Promise<boolean | void>;
168
219
  accountBridge?: DeferredLinkAccountBridge<Action>;
169
220
  isAccountReady?: () => boolean;
@@ -179,11 +230,31 @@ export interface DeferredLinkClientOptions<Action extends string = DeferredLinkA
179
230
  requestTimeoutMs?: number;
180
231
  retryBaseMs?: number;
181
232
  retryMaxMs?: number;
233
+ /** Timeout for one terminal-result acknowledgement attempt. Defaults to requestTimeoutMs. */
234
+ terminalDeliveryTimeoutMs?: number;
182
235
  tokenMaxAgeMs?: number;
183
236
  recentInstallMaxAgeMs?: number;
184
237
  autoRetry?: boolean;
185
238
  reportResolverOutcomes?: boolean;
186
239
  onOutcome?: (event: DeferredLinkOutcomeEvent) => void | Promise<void>;
240
+ /**
241
+ * Synchronous privacy gate evaluated before a terminal record or eventId is created. A false
242
+ * result or throw completes first-open matching without retaining terminal metadata.
243
+ */
244
+ shouldQueueFirstOpenResult?: () => boolean;
245
+ /**
246
+ * Durable at-least-once terminal delivery. Return `accepted` (or legacy `true`) only after the
247
+ * stable eventId is durably accepted, `retry`/false to replay it, or `drop` to deliberately and
248
+ * durably discard it (for example after consent revocation). Throw, rejection and timeout retry
249
+ * the same eventId. `drop` is sticky: failed local deletion retries never re-enter the receiver.
250
+ * Receivers must deduplicate because a crash after acceptance can replay it. When this handler
251
+ * is absent no terminal record is created or retained.
252
+ */
253
+ onFirstOpenResult?: (
254
+ outcome: AnonymousFirstOpenTerminalOutcome,
255
+ ) => AnonymousFirstOpenTerminalDisposition
256
+ | boolean
257
+ | Promise<AnonymousFirstOpenTerminalDisposition | boolean>;
187
258
  onStateChange?: (state: DeferredLinkState) => void;
188
259
  onError?: (error: unknown) => void;
189
260
  now?: () => number;
@@ -255,6 +326,29 @@ const DEFAULT_ACTIONS = [
255
326
  'refresh_entitlement',
256
327
  ] as const;
257
328
  const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'] as const;
329
+ const PROBABILISTIC_ACTIONS = new Set<string>(['open_home', 'open_premium']);
330
+ const CAPTURE_PRIORITY: Readonly<Record<DeferredLinkCaptureBasis, number>> = {
331
+ direct_token: 3,
332
+ ios_user_paste: 2,
333
+ android_install_referrer: 1,
334
+ };
335
+ const NO_ROUTE_REASONS = new Set<AnonymousFirstOpenTerminalReason>([
336
+ 'unmatched',
337
+ 'ambiguous',
338
+ 'holdout',
339
+ 'low_confidence',
340
+ 'shadow_would_route',
341
+ 'shadow_attributed',
342
+ 'analytics_attributed',
343
+ 'target_revoked',
344
+ 'disabled',
345
+ 'expired_replay',
346
+ ]);
347
+ const FAILURE_REASONS = new Set<AnonymousFirstOpenTerminalReason>([
348
+ 'invalid_request',
349
+ 'invalid_response',
350
+ 'policy_rejected',
351
+ ]);
258
352
  const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
259
353
  const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
260
354
  const DEFAULT_RETRY_BASE_MS = 15_000;
@@ -265,6 +359,7 @@ const MAX_APPLIED_IDS = 32;
265
359
  const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
266
360
  const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
267
361
  const MAX_PERSISTED_BYTES = 131_072;
362
+ const ASYNCHRONOUS_STORAGE_ADAPTERS = new WeakSet<object>();
268
363
  const VERSION_SIGNAL = /^[A-Za-z0-9][A-Za-z0-9._+()-]*$/;
269
364
  const DEVICE_MODEL_CODE = /^[A-Za-z0-9][A-Za-z0-9._,+-]*$/;
270
365
  const DISTRIBUTION_SIGNAL = /^[a-z0-9][a-z0-9._-]*$/;
@@ -293,6 +388,24 @@ interface QueuedResolverOutcome {
293
388
  nextRetryAt: number;
294
389
  }
295
390
 
391
+ interface QueuedFirstOpenTerminalDelivery extends AnonymousFirstOpenTerminalOutcome {
392
+ disposition?: never;
393
+ attempts: number;
394
+ nextRetryAt: number;
395
+ }
396
+
397
+ /** Minimal sticky privacy tombstone. It carries no terminal outcome or attribution metadata. */
398
+ interface DroppedFirstOpenTerminalTombstone {
399
+ eventId: string;
400
+ disposition: 'drop';
401
+ attempts: number;
402
+ nextRetryAt: number;
403
+ }
404
+
405
+ type PersistedFirstOpenTerminalDelivery =
406
+ | QueuedFirstOpenTerminalDelivery
407
+ | DroppedFirstOpenTerminalTombstone;
408
+
296
409
  interface PersistedDeferredLinkState {
297
410
  version: 1;
298
411
  status: DeferredLinkStatus;
@@ -303,6 +416,8 @@ interface PersistedDeferredLinkState {
303
416
  notifiedOutcomes: string[];
304
417
  /** Durable Encore delivery queue. Exposure tokens stay local and are used only in URL paths. */
305
418
  outcomeQueue: QueuedResolverOutcome[];
419
+ /** One-record at-least-once application delivery outbox, independent of matcher completion. */
420
+ terminalDelivery: PersistedFirstOpenTerminalDelivery | null;
306
421
  firstOpen: DeferredLinkFirstOpenState;
307
422
  }
308
423
 
@@ -319,6 +434,7 @@ const emptyState = (): PersistedDeferredLinkState => ({
319
434
  appliedIds: [],
320
435
  notifiedOutcomes: [],
321
436
  outcomeQueue: [],
437
+ terminalDelivery: null,
322
438
  firstOpen: {
323
439
  installAttemptId: null,
324
440
  completed: false,
@@ -430,6 +546,12 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
430
546
  }
431
547
  const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
432
548
  const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
549
+ const terminalDeliveryTimeoutMs = boundedDuration(
550
+ options.terminalDeliveryTimeoutMs,
551
+ requestTimeoutMs,
552
+ 100,
553
+ 60_000,
554
+ );
433
555
  const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
434
556
  const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
435
557
  const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
@@ -443,11 +565,21 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
443
565
  let state = readState(options.storage, storageKey);
444
566
  let disposed = false;
445
567
  let processing: Promise<void> | null = null;
568
+ let firstOpenMatching: Promise<AnonymousFirstOpenResult> | null = null;
446
569
  let processRequested = false;
447
570
  let retryTimer: ReturnType<typeof setTimeout> | null = null;
448
571
  let outcomeSending: Promise<number> | null = null;
572
+ let terminalDeliverySending: Promise<boolean> | null = null;
573
+ let terminalDeliveryGeneration = 0;
449
574
  let accountRetryAt = 0;
450
575
  let accountAttempts = 0;
576
+ let deterministicEpoch = 0;
577
+ // Host navigation is irreversible once any routing callback has started: it may perform its
578
+ // side effect synchronously before returning a Promise. Serialize captures against that commit
579
+ // boundary and keep the newer accepted token durable for the next client lifecycle instead of
580
+ // automatically opening a second journey behind the first one.
581
+ let routingApplicationInFlight = false;
582
+ let deterministicTokenDeferredAfterCommittedJourney: string | null = null;
451
583
  let lastFirstOpenContext: AnonymousFirstOpenContext | null = null;
452
584
  let unsubscribeAccount: (() => void) | null = null;
453
585
  const listeners = new Set<(value: DeferredLinkState) => void>();
@@ -456,6 +588,24 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
456
588
  try { options.onError?.(error); } catch { /* diagnostic hooks never break link handling */ }
457
589
  };
458
590
 
591
+ // A build without a receiver has not opted into retaining this analytics envelope. First replace
592
+ // an old full record with the same minimal sticky tombstone used by an explicit drop. Deletion
593
+ // may fail, but a later app version can then only retry local deletion, never resurrect delivery.
594
+ if (!options.onFirstOpenResult
595
+ && state.terminalDelivery
596
+ && state.terminalDelivery.disposition !== 'drop') {
597
+ const tombstone: DroppedFirstOpenTerminalTombstone = {
598
+ eventId: state.terminalDelivery.eventId,
599
+ disposition: 'drop',
600
+ attempts: state.terminalDelivery.attempts,
601
+ nextRetryAt: 0,
602
+ };
603
+ state = { ...state, terminalDelivery: tombstone };
604
+ if (!writeState(options.storage, storageKey, state)) {
605
+ reportError(new Error('Pulse Links: stale terminal delivery tombstone was not persisted'));
606
+ }
607
+ }
608
+
459
609
  const snapshot = (): DeferredLinkState => ({
460
610
  status: state.status,
461
611
  pending: state.pending ? { ...state.pending } : null,
@@ -464,8 +614,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
464
614
  firstOpen: { ...state.firstOpen },
465
615
  });
466
616
 
467
- const persistAndNotify = (): void => {
468
- writeState(options.storage, storageKey, state);
617
+ const notifyState = (): void => {
469
618
  const value = snapshot();
470
619
  for (const listener of listeners) {
471
620
  try { listener(value); } catch (error) { reportError(error); }
@@ -474,6 +623,12 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
474
623
  scheduleWake();
475
624
  };
476
625
 
626
+ const persistAndNotify = (): boolean => {
627
+ const persisted = writeState(options.storage, storageKey, state);
628
+ notifyState();
629
+ return persisted;
630
+ };
631
+
477
632
  const patchState = (patch: Partial<PersistedDeferredLinkState>): void => {
478
633
  state = { ...state, ...patch };
479
634
  persistAndNotify();
@@ -484,6 +639,229 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
484
639
  persistAndNotify();
485
640
  };
486
641
 
642
+ const terminalDeliveryOutcome = (
643
+ queued: QueuedFirstOpenTerminalDelivery,
644
+ ): AnonymousFirstOpenTerminalOutcome => ({
645
+ eventId: queued.eventId,
646
+ status: queued.status,
647
+ rail: queued.rail,
648
+ routed: queued.routed,
649
+ retryable: false,
650
+ reason: queued.reason,
651
+ occurredAt: queued.occurredAt,
652
+ ...(queued.matchBasis !== undefined ? { matchBasis: queued.matchBasis } : {}),
653
+ ...(queued.confidence !== undefined ? { confidence: queued.confidence } : {}),
654
+ ...(queued.campaignId !== undefined ? { campaignId: queued.campaignId } : {}),
655
+ ...(queued.experimentId !== undefined ? { experimentId: queued.experimentId } : {}),
656
+ ...(queued.variantId !== undefined ? { variantId: queued.variantId } : {}),
657
+ });
658
+
659
+ const scheduleTerminalDeliveryRetry = (
660
+ queued: PersistedFirstOpenTerminalDelivery,
661
+ ): void => {
662
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return;
663
+ const attempts = Math.min(queued.attempts + 1, 100_000);
664
+ state = {
665
+ ...state,
666
+ terminalDelivery: {
667
+ ...queued,
668
+ attempts,
669
+ nextRetryAt: now() + resolverOutcomeRetryDelay(
670
+ queued.eventId,
671
+ attempts,
672
+ retryBaseMs,
673
+ retryMaxMs,
674
+ ),
675
+ },
676
+ };
677
+ persistAndNotify();
678
+ };
679
+
680
+ const acknowledgeTerminalDelivery = (
681
+ queued: PersistedFirstOpenTerminalDelivery,
682
+ ): boolean => {
683
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
684
+ const acknowledgedState: PersistedDeferredLinkState = {
685
+ ...state,
686
+ terminalDelivery: null,
687
+ };
688
+ // Clearing the record is itself transactional. If this write fails after the receiver
689
+ // accepted the event, retain and replay the same eventId: that is why the contract is
690
+ // at-least-once and why receivers must deduplicate.
691
+ if (!writeState(options.storage, storageKey, acknowledgedState, false)) {
692
+ reportError(new Error('Pulse Links: terminal delivery acknowledgement was not persisted'));
693
+ scheduleTerminalDeliveryRetry(queued);
694
+ return false;
695
+ }
696
+ state = acknowledgedState;
697
+ notifyState();
698
+ return true;
699
+ };
700
+
701
+ const flushTerminalDelivery = (): Promise<boolean> => {
702
+ if (disposed) return Promise.resolve(false);
703
+ if (terminalDeliverySending) return terminalDeliverySending;
704
+ const queued = state.terminalDelivery;
705
+ if (!queued || queued.nextRetryAt > now()) return Promise.resolve(false);
706
+ if (queued.disposition === 'drop') {
707
+ return Promise.resolve(acknowledgeTerminalDelivery(queued));
708
+ }
709
+ if (!options.onFirstOpenResult) return Promise.resolve(false);
710
+ const generation = terminalDeliveryGeneration;
711
+
712
+ const run = async (): Promise<boolean> => {
713
+ // Never call the receiver before the complete record is durable. With no storage adapter,
714
+ // this is an explicitly memory-only degradation rather than a cross-restart guarantee.
715
+ if (!writeState(options.storage, storageKey, state)) {
716
+ reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
717
+ scheduleTerminalDeliveryRetry(queued);
718
+ return false;
719
+ }
720
+
721
+ let rawDisposition:
722
+ | AnonymousFirstOpenTerminalDisposition
723
+ | boolean
724
+ | Promise<AnonymousFirstOpenTerminalDisposition | boolean>;
725
+ try {
726
+ rawDisposition = options.onFirstOpenResult!(terminalDeliveryOutcome(queued));
727
+ } catch (error) {
728
+ reportError(error);
729
+ scheduleTerminalDeliveryRetry(queued);
730
+ return false;
731
+ }
732
+
733
+ let disposition: AnonymousFirstOpenTerminalDisposition;
734
+ if (typeof rawDisposition === 'boolean' || typeof rawDisposition === 'string') {
735
+ disposition = normalizeTerminalDeliveryDisposition(rawDisposition);
736
+ } else {
737
+ try {
738
+ const resolved = await withTerminalDeliveryTimeout(
739
+ Promise.resolve(rawDisposition),
740
+ terminalDeliveryTimeoutMs,
741
+ );
742
+ disposition = normalizeTerminalDeliveryDisposition(resolved);
743
+ } catch (error) {
744
+ if (!disposed && terminalDeliveryGeneration === generation) reportError(error);
745
+ if (!disposed && terminalDeliveryGeneration === generation) {
746
+ scheduleTerminalDeliveryRetry(queued);
747
+ }
748
+ return false;
749
+ }
750
+ }
751
+
752
+ if (disposed
753
+ || terminalDeliveryGeneration !== generation
754
+ || state.terminalDelivery?.eventId !== queued.eventId) return false;
755
+ if (disposition === 'accepted') return acknowledgeTerminalDelivery(queued);
756
+ if (disposition === 'drop') return dropTerminalDelivery(queued);
757
+ scheduleTerminalDeliveryRetry(queued);
758
+ return false;
759
+ };
760
+
761
+ let owned: Promise<boolean>;
762
+ owned = run().finally(() => {
763
+ if (terminalDeliverySending === owned) terminalDeliverySending = null;
764
+ const pending = state.terminalDelivery;
765
+ if (!disposed && options.onFirstOpenResult && pending && pending.nextRetryAt <= now()) {
766
+ void flushTerminalDelivery();
767
+ }
768
+ });
769
+ terminalDeliverySending = owned;
770
+ return owned;
771
+ };
772
+
773
+ const dropTerminalDelivery = (
774
+ queued: QueuedFirstOpenTerminalDelivery,
775
+ ): boolean => {
776
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
777
+ const tombstone: DroppedFirstOpenTerminalTombstone = {
778
+ eventId: queued.eventId,
779
+ disposition: 'drop',
780
+ attempts: queued.attempts,
781
+ nextRetryAt: 0,
782
+ };
783
+ state = { ...state, terminalDelivery: tombstone };
784
+ // Persist the sticky disposition before trying to delete it. A failed local deletion can then
785
+ // retry only deletion after restart; it must never call the receiver or resurrect collection.
786
+ if (!writeState(options.storage, storageKey, state)) {
787
+ reportError(new Error('Pulse Links: terminal drop tombstone was not persisted'));
788
+ scheduleTerminalDeliveryRetry(tombstone);
789
+ return false;
790
+ }
791
+ notifyState();
792
+ return acknowledgeTerminalDelivery(tombstone);
793
+ };
794
+
795
+ const completeFirstOpen = (
796
+ status: DeferredLinkStatus,
797
+ outcome: Omit<AnonymousFirstOpenTerminalOutcome, 'eventId' | 'occurredAt' | 'retryable'>,
798
+ ): void => {
799
+ let shouldQueue = Boolean(options.onFirstOpenResult);
800
+ if (shouldQueue && options.shouldQueueFirstOpenResult) {
801
+ try {
802
+ shouldQueue = options.shouldQueueFirstOpenResult() === true;
803
+ } catch (error) {
804
+ shouldQueue = false;
805
+ reportError(error);
806
+ }
807
+ }
808
+ if (!shouldQueue) {
809
+ state = {
810
+ ...state,
811
+ status,
812
+ terminalDelivery: null,
813
+ firstOpen: {
814
+ ...state.firstOpen,
815
+ completed: true,
816
+ attempts: 0,
817
+ nextRetryAt: 0,
818
+ },
819
+ };
820
+ persistAndNotify();
821
+ return;
822
+ }
823
+
824
+ const existingEventIds = new Set<string>([
825
+ ...state.outcomeQueue.map((queued) => queued.eventId),
826
+ ...(state.firstOpen.installAttemptId ? [state.firstOpen.installAttemptId] : []),
827
+ ...(state.terminalDelivery ? [state.terminalDelivery.eventId] : []),
828
+ ]);
829
+ const delivery = state.terminalDelivery ?? {
830
+ ...outcome,
831
+ eventId: makeOutcomeEventId(options.randomUUID, existingEventIds),
832
+ retryable: false as const,
833
+ occurredAt: new Date(now()).toISOString(),
834
+ attempts: 0,
835
+ nextRetryAt: 0,
836
+ };
837
+ state = {
838
+ ...state,
839
+ status,
840
+ terminalDelivery: delivery,
841
+ firstOpen: {
842
+ ...state.firstOpen,
843
+ completed: true,
844
+ attempts: 0,
845
+ nextRetryAt: 0,
846
+ },
847
+ };
848
+ if (!persistAndNotify()) {
849
+ reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
850
+ }
851
+ // Delivery is deliberately detached from routing/matcher completion.
852
+ void flushTerminalDelivery();
853
+ };
854
+
855
+ const blockFirstOpenForDeterministic = (): void => {
856
+ deterministicEpoch += 1;
857
+ if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return;
858
+ state = {
859
+ ...state,
860
+ firstOpen: { ...state.firstOpen, completed: true, nextRetryAt: 0 },
861
+ };
862
+ persistAndNotify();
863
+ };
864
+
487
865
  const isAccountReady = (): boolean => {
488
866
  try { return options.isAccountReady?.() ?? false; } catch (error) {
489
867
  reportError(error);
@@ -740,9 +1118,25 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
740
1118
  return false;
741
1119
  }
742
1120
 
1121
+ let applied: boolean | void = false;
1122
+ let applicationFailed = false;
1123
+ let applicationError: unknown;
1124
+ const applicationEpoch = deterministicEpoch;
1125
+ const hasNewerPendingIntent = (): boolean => deterministicEpoch !== applicationEpoch
1126
+ && state.pending !== null
1127
+ // A provenance upgrade or rejected lower-priority capture for the token already being
1128
+ // applied does not represent another destination and must not leave that token pending.
1129
+ && (sourceToken === null || state.pending.token !== sourceToken);
1130
+ routingApplicationInFlight = true;
743
1131
  emitOutcome(link, 'app_open_confirmed');
744
1132
  emitOutcome(link, 'deferred_link_resolved');
745
- let applied: boolean | void;
1133
+ // `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
1134
+ // a newer accepted destination, it still arrived before the routing callback and must win.
1135
+ if (hasNewerPendingIntent()) {
1136
+ routingApplicationInFlight = false;
1137
+ void process();
1138
+ return false;
1139
+ }
746
1140
  try {
747
1141
  if (link.deepLink) {
748
1142
  applied = await options.onDeepLink(link.deepLink, link);
@@ -753,24 +1147,51 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
753
1147
  return false;
754
1148
  }
755
1149
  } catch (error) {
756
- reportError(error);
1150
+ applicationFailed = true;
1151
+ applicationError = error;
1152
+ } finally {
1153
+ routingApplicationInFlight = false;
1154
+ }
1155
+
1156
+ const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
1157
+ if (applicationFailed) {
1158
+ reportError(applicationError);
1159
+ if (newerIntentArrivedDuringApplication) {
1160
+ // The current action did not commit. Resume the newer accepted intent that was captured
1161
+ // while its callback was suspended, without scheduling a retry for the superseded one.
1162
+ void process();
1163
+ return false;
1164
+ }
757
1165
  if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();
758
1166
  else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();
759
1167
  else scheduleAccountRetry();
760
1168
  return false;
761
1169
  }
762
1170
  if (applied === false) {
1171
+ if (newerIntentArrivedDuringApplication) {
1172
+ void process();
1173
+ return false;
1174
+ }
763
1175
  if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();
764
1176
  else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();
765
1177
  else scheduleAccountRetry();
766
1178
  return false;
767
1179
  }
768
1180
 
1181
+ if (newerIntentArrivedDuringApplication) {
1182
+ // Invocation of any host routing callback is the last safe commit boundary. A successful
1183
+ // callback may already have navigated, so automatically applying the newly captured token
1184
+ // now would create two journeys. Keep it pending and let a fresh SDK lifecycle recover it.
1185
+ deterministicTokenDeferredAfterCommittedJourney = state.pending!.token;
1186
+ }
1187
+
769
1188
  const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
770
1189
  const newlyAppliedSet = new Set(newlyApplied);
771
1190
  const appliedIds = [...state.appliedIds.filter((id) => !newlyAppliedSet.has(id)), ...newlyApplied]
772
1191
  .slice(-MAX_APPLIED_IDS);
773
- const stillPending = sourceToken !== null && state.pending?.token !== sourceToken;
1192
+ const stillPending = sourceToken !== null
1193
+ ? state.pending?.token !== sourceToken
1194
+ : newerIntentArrivedDuringApplication;
774
1195
  state = {
775
1196
  ...state,
776
1197
  status: stillPending ? 'pending' : 'applied',
@@ -784,7 +1205,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
784
1205
  accountRetryAt = 0;
785
1206
  }
786
1207
  emitOutcome(link, 'action_applied');
787
- if (stillPending) void process();
1208
+ if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
1209
+ void process();
1210
+ }
788
1211
  return true;
789
1212
  };
790
1213
 
@@ -822,8 +1245,13 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
822
1245
 
823
1246
  const processOnce = async (): Promise<void> => {
824
1247
  if (disposed) return;
1248
+ // `capture()` can re-enter while any host callback is awaiting. Never start another routing
1249
+ // application concurrently, and never auto-drain the exact token preserved after an already
1250
+ // committed journey in this client lifecycle.
1251
+ if (routingApplicationInFlight) return;
825
1252
  const pending = state.pending;
826
1253
  if (pending) {
1254
+ if (pending.token === deterministicTokenDeferredAfterCommittedJourney) return;
827
1255
  if (pending.nextRetryAt > now()) return;
828
1256
  if (now() - pending.receivedAt > tokenMaxAgeMs) {
829
1257
  clearPendingAsTerminal();
@@ -847,7 +1275,11 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
847
1275
  return;
848
1276
  }
849
1277
  try {
850
- const raw = await options.accountBridge.claim(token);
1278
+ const raw = await withPromiseTimeout(
1279
+ () => options.accountBridge!.claim(token),
1280
+ requestTimeoutMs,
1281
+ 'account claim',
1282
+ );
851
1283
  if (disposed || state.pending?.token !== token) return;
852
1284
  const normalizedClaim = normalizeResolved<Action>(
853
1285
  raw,
@@ -862,7 +1294,10 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
862
1294
  matchGuaranteed: true,
863
1295
  confidence: 1,
864
1296
  } : null;
865
- if (claimed) await applyResolved(claimed, token);
1297
+ if (claimed) {
1298
+ blockFirstOpenForDeterministic();
1299
+ await applyResolved(claimed, token);
1300
+ }
866
1301
  else clearPendingAsTerminal();
867
1302
  } catch (error) {
868
1303
  reportError(error);
@@ -872,8 +1307,17 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
872
1307
  }
873
1308
 
874
1309
  if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
1310
+ const accountPendingEpoch = deterministicEpoch;
875
1311
  try {
876
- const raw = await options.accountBridge.pending();
1312
+ const raw = await withPromiseTimeout(
1313
+ () => options.accountBridge!.pending(),
1314
+ requestTimeoutMs,
1315
+ 'account pending',
1316
+ );
1317
+ // A URL/paste/referrer captured while the account lookup was in flight is newer explicit
1318
+ // user intent. Ignore the stale bridge response and let the requested drain resolve the
1319
+ // captured token; otherwise both deterministic destinations could be applied.
1320
+ if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
877
1321
  if (!raw) {
878
1322
  accountAttempts = 0;
879
1323
  accountRetryAt = 0;
@@ -898,12 +1342,19 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
898
1342
  matchGuaranteed: true,
899
1343
  confidence: 1,
900
1344
  } : null;
901
- if (pendingLink && !state.appliedIds.includes(pendingLink.id)) {
902
- await applyResolved(pendingLink, null);
903
- } else if (pendingLink && state.status !== 'applied') {
904
- patchState({ status: 'applied' });
1345
+ if (pendingLink) {
1346
+ // Reserve the first-open journey for deterministic recovery before invoking any host
1347
+ // callback. This also covers an already-applied account item restored from older SDK
1348
+ // state, which must still prevent a second probabilistic destination.
1349
+ blockFirstOpenForDeterministic();
1350
+ if (!state.appliedIds.includes(pendingLink.id)) {
1351
+ await applyResolved(pendingLink, null);
1352
+ } else if (state.status !== 'applied') {
1353
+ patchState({ status: 'applied' });
1354
+ }
905
1355
  }
906
1356
  } catch (error) {
1357
+ if (disposed || deterministicEpoch !== accountPendingEpoch || state.pending) return;
907
1358
  reportError(error);
908
1359
  // No token was consumed; the authenticated server outbox remains authoritative.
909
1360
  scheduleAccountRetry();
@@ -912,6 +1363,8 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
912
1363
 
913
1364
  const process = async (): Promise<void> => {
914
1365
  if (disposed) return;
1366
+ // Application delivery is independent: never await it on the routing drain.
1367
+ void flushTerminalDelivery();
915
1368
  processRequested = true;
916
1369
  if (processing) return processing;
917
1370
  const run = (async () => {
@@ -938,11 +1391,13 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
938
1391
  const token = normalizeDeferredHandoffToken(rawToken);
939
1392
  if (!token) return false;
940
1393
  if (state.appliedIds.includes(token)) {
1394
+ deterministicEpoch += 1;
941
1395
  if (!state.firstOpen.completed) patchFirstOpen({ completed: true, nextRetryAt: 0 });
942
1396
  return true;
943
1397
  }
944
1398
  if (state.pending?.token === token) {
945
- if (state.pending.matchBasis === 'direct_token' && basis !== 'direct_token') {
1399
+ deterministicEpoch += 1;
1400
+ if (CAPTURE_PRIORITY[basis] > CAPTURE_PRIORITY[state.pending.matchBasis]) {
946
1401
  state = {
947
1402
  ...state,
948
1403
  pending: { ...state.pending, matchBasis: basis },
@@ -955,6 +1410,19 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
955
1410
  void process();
956
1411
  return true;
957
1412
  }
1413
+
1414
+ // Exact transports are deterministic, but when more than one arrives during cold start their
1415
+ // provenance still has an explicit precedence. A lower-priority late callback must not replace
1416
+ // the URL the user intentionally opened; equal priority keeps last-touch behaviour.
1417
+ if (state.pending
1418
+ && CAPTURE_PRIORITY[basis] < CAPTURE_PRIORITY[state.pending.matchBasis]) {
1419
+ deterministicEpoch += 1;
1420
+ if (!state.firstOpen.completed) patchFirstOpen({ completed: true, nextRetryAt: 0 });
1421
+ void process();
1422
+ return true;
1423
+ }
1424
+
1425
+ deterministicEpoch += 1;
958
1426
  state = {
959
1427
  ...state,
960
1428
  status: 'pending',
@@ -976,7 +1444,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
976
1444
  return true;
977
1445
  };
978
1446
 
979
- const matchFirstOpen = async (
1447
+ const matchFirstOpenOnce = async (
980
1448
  context: AnonymousFirstOpenContext,
981
1449
  ): Promise<AnonymousFirstOpenResult> => {
982
1450
  if (disposed) return 'ineligible';
@@ -986,8 +1454,21 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
986
1454
  if (state.firstOpen.nextRetryAt > now()) return 'backoff';
987
1455
  if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
988
1456
 
1457
+ // The client starts account recovery at construction. Join that deterministic rail before
1458
+ // asking the probabilistic matcher; otherwise a slow Billing/account response can navigate
1459
+ // after the probabilistic destination and the user observes both journeys.
1460
+ await process();
1461
+ if (disposed) return 'ineligible';
1462
+ if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
1463
+ if (options.accountBridge && isAccountReady() && accountRetryAt > now()) {
1464
+ if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1465
+ return 'retry_scheduled';
1466
+ }
1467
+ if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
1468
+
989
1469
  const installAttemptId = state.firstOpen.installAttemptId
990
1470
  ?? makeInstallAttemptId(options.randomUUID);
1471
+ const startingDeterministicEpoch = deterministicEpoch;
991
1472
  state = {
992
1473
  ...state,
993
1474
  status: 'resolving',
@@ -1018,16 +1499,27 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1018
1499
  },
1019
1500
  requestTimeoutMs,
1020
1501
  );
1021
- if (state.pending) return 'deterministic_pending';
1022
- if (state.firstOpen.completed) return 'deterministic_pending';
1502
+ if (disposed) return 'ineligible';
1503
+ if (deterministicEpoch !== startingDeterministicEpoch
1504
+ || state.pending
1505
+ || state.firstOpen.completed) return 'deterministic_pending';
1023
1506
  if (response.status === 204) {
1024
- patchFirstOpen({ completed: true, attempts: 0, nextRetryAt: 0 });
1025
- patchState({ status: 'idle' });
1507
+ completeFirstOpen('idle', {
1508
+ status: 'NOT_FOUND',
1509
+ rail: 'no_route',
1510
+ routed: false,
1511
+ reason: firstOpenNoRouteReason(response),
1512
+ });
1026
1513
  return 'no_match';
1027
1514
  }
1028
- if (response.status === 400 || response.status === 404 || response.status === 410) {
1029
- patchFirstOpen({ completed: true, nextRetryAt: 0 });
1030
- patchState({ status: 'terminal_error' });
1515
+ if (response.status === 400 || response.status === 404
1516
+ || response.status === 410 || response.status === 413) {
1517
+ completeFirstOpen('terminal_error', {
1518
+ status: 'FAILURE',
1519
+ rail: 'no_route',
1520
+ routed: false,
1521
+ reason: 'invalid_request',
1522
+ });
1031
1523
  return 'terminal_error';
1032
1524
  }
1033
1525
  if (!response.ok) {
@@ -1035,38 +1527,113 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1035
1527
  scheduleFirstOpenRetry();
1036
1528
  return 'retry_scheduled';
1037
1529
  }
1038
- patchFirstOpen({ completed: true, nextRetryAt: 0 });
1039
- patchState({ status: 'terminal_error' });
1530
+ completeFirstOpen('terminal_error', {
1531
+ status: 'FAILURE',
1532
+ rail: 'no_route',
1533
+ routed: false,
1534
+ reason: 'invalid_response',
1535
+ });
1536
+ return 'terminal_error';
1537
+ }
1538
+ let rawLink: unknown;
1539
+ try {
1540
+ rawLink = await response.json();
1541
+ } catch {
1542
+ completeFirstOpen('terminal_error', {
1543
+ status: 'FAILURE',
1544
+ rail: 'no_route',
1545
+ routed: false,
1546
+ reason: 'invalid_response',
1547
+ });
1040
1548
  return 'terminal_error';
1041
1549
  }
1550
+ if (disposed) return 'ineligible';
1551
+ if (deterministicEpoch !== startingDeterministicEpoch
1552
+ || state.pending
1553
+ || state.firstOpen.completed) return 'deterministic_pending';
1042
1554
  const link = normalizeResolved<Action>(
1043
- await response.json(),
1555
+ rawLink,
1044
1556
  installAttemptId.replace(/-/g, ''),
1045
1557
  'unique_probabilistic',
1046
1558
  allowedActions,
1047
1559
  now(),
1048
1560
  );
1049
- // The probabilistic endpoint can personalize harmless UX only. It can never upgrade its own
1050
- // evidence to guaranteed or return a deterministic/account basis.
1051
- if (!link || link.matchBasis !== 'unique_probabilistic' || link.matchGuaranteed
1052
- || (link.action && sensitiveActions.has(link.action))) {
1053
- patchFirstOpen({ completed: true, nextRetryAt: 0 });
1054
- patchState({ status: 'terminal_error' });
1561
+ if (!link) {
1562
+ completeFirstOpen('terminal_error', {
1563
+ status: 'FAILURE',
1564
+ rail: 'no_route',
1565
+ routed: false,
1566
+ reason: 'invalid_response',
1567
+ });
1055
1568
  return 'terminal_error';
1056
1569
  }
1570
+ // A probabilistic result is navigation intent, never a remote URL transport. Only the two
1571
+ // harmless closed discovery actions are accepted; all billing/account/entitlement actions
1572
+ // and even an app-owned deepLink are rejected before application code runs.
1573
+ if (link.matchBasis !== 'unique_probabilistic'
1574
+ || link.matchGuaranteed
1575
+ || link.deepLink !== undefined
1576
+ || !link.action
1577
+ || !PROBABILISTIC_ACTIONS.has(String(link.action))
1578
+ || sensitiveActions.has(link.action)
1579
+ || !options.onAction) {
1580
+ completeFirstOpen('terminal_error', {
1581
+ status: 'FAILURE',
1582
+ rail: 'no_route',
1583
+ routed: false,
1584
+ reason: 'policy_rejected',
1585
+ });
1586
+ return 'terminal_error';
1587
+ }
1588
+ if (deterministicEpoch !== startingDeterministicEpoch
1589
+ || state.pending
1590
+ || state.firstOpen.completed) return 'deterministic_pending';
1057
1591
  if (await applyResolved(link, null)) {
1058
- patchFirstOpen({ completed: true, attempts: 0, nextRetryAt: 0 });
1592
+ // `applyResolved()` returns true only after the host callback crossed its irreversible
1593
+ // commit boundary successfully. A deterministic capture can arrive while that callback
1594
+ // awaits; it remains pending for the next lifecycle, but it must not erase the terminal
1595
+ // truth that this probabilistic journey really routed.
1596
+ completeFirstOpen(state.pending ? 'pending' : 'applied', {
1597
+ status: 'FOUND',
1598
+ rail: 'fast_route',
1599
+ routed: true,
1600
+ reason: 'matched',
1601
+ matchBasis: link.matchBasis,
1602
+ confidence: link.confidence,
1603
+ ...(link.campaignId !== undefined ? { campaignId: link.campaignId } : {}),
1604
+ ...(link.experimentId !== undefined ? { experimentId: link.experimentId } : {}),
1605
+ ...(link.variantId !== undefined ? { variantId: link.variantId } : {}),
1606
+ });
1059
1607
  return 'matched';
1060
1608
  }
1609
+ if (deterministicEpoch !== startingDeterministicEpoch
1610
+ || state.pending
1611
+ || state.firstOpen.completed) return 'deterministic_pending';
1061
1612
  if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1062
1613
  return 'retry_scheduled';
1063
1614
  } catch (error) {
1615
+ if (disposed) return 'ineligible';
1064
1616
  reportError(error);
1617
+ if (deterministicEpoch !== startingDeterministicEpoch
1618
+ || state.pending
1619
+ || state.firstOpen.completed) return 'deterministic_pending';
1065
1620
  scheduleFirstOpenRetry();
1066
1621
  return 'retry_scheduled';
1067
1622
  }
1068
1623
  };
1069
1624
 
1625
+ const matchFirstOpen = (
1626
+ context: AnonymousFirstOpenContext,
1627
+ ): Promise<AnonymousFirstOpenResult> => {
1628
+ if (firstOpenMatching) return firstOpenMatching;
1629
+ let owned: Promise<AnonymousFirstOpenResult>;
1630
+ owned = matchFirstOpenOnce(context).finally(() => {
1631
+ if (firstOpenMatching === owned) firstOpenMatching = null;
1632
+ });
1633
+ firstOpenMatching = owned;
1634
+ return owned;
1635
+ };
1636
+
1070
1637
  const captureAndroidInstallReferrer = async (
1071
1638
  bridge: AndroidInstallReferrerBridge,
1072
1639
  ): Promise<AndroidInstallReferrerResult> => {
@@ -1095,6 +1662,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1095
1662
  lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0,
1096
1663
  accountRetryAt,
1097
1664
  state.outcomeQueue[0]?.nextRetryAt ?? 0,
1665
+ state.terminalDelivery?.nextRetryAt ?? 0,
1098
1666
  ].filter((value) => value > now());
1099
1667
  if (candidates.length === 0) return;
1100
1668
  const next = Math.min(...candidates);
@@ -1102,6 +1670,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1102
1670
  retryTimer = null;
1103
1671
  void process();
1104
1672
  void flushResolverOutcomes();
1673
+ void flushTerminalDelivery();
1105
1674
  if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
1106
1675
  void matchFirstOpen(lastFirstOpenContext);
1107
1676
  }
@@ -1120,6 +1689,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1120
1689
  scheduleWake();
1121
1690
  void process();
1122
1691
  void flushResolverOutcomes();
1692
+ void flushTerminalDelivery();
1123
1693
 
1124
1694
  return {
1125
1695
  capture,
@@ -1138,6 +1708,8 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1138
1708
  dispose: () => {
1139
1709
  if (disposed) return;
1140
1710
  disposed = true;
1711
+ deterministicEpoch += 1;
1712
+ terminalDeliveryGeneration += 1;
1141
1713
  if (retryTimer) clearTimeout(retryTimer);
1142
1714
  retryTimer = null;
1143
1715
  unsubscribeAccount?.();
@@ -1147,6 +1719,10 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1147
1719
  reset: () => {
1148
1720
  accountRetryAt = 0;
1149
1721
  accountAttempts = 0;
1722
+ deterministicEpoch += 1;
1723
+ terminalDeliveryGeneration += 1;
1724
+ terminalDeliverySending = null;
1725
+ deterministicTokenDeferredAfterCommittedJourney = null;
1150
1726
  state = emptyState();
1151
1727
  persistAndNotify();
1152
1728
  },
@@ -1199,11 +1775,11 @@ function stableOutcomeKey(linkId: string, name: DeferredLinkOutcomeName): string
1199
1775
  return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
1200
1776
  }
1201
1777
 
1202
- function boundedMetadata(value: unknown): string | null | undefined {
1778
+ function boundedMetadata(value: unknown, maxLength = 256): string | null | undefined {
1203
1779
  if (value === null) return null;
1204
1780
  if (typeof value !== 'string' && typeof value !== 'number') return undefined;
1205
1781
  const result = String(value).trim();
1206
- return result.length > 0 && result.length <= 256 ? result : undefined;
1782
+ return result.length > 0 && result.length <= maxLength ? result : undefined;
1207
1783
  }
1208
1784
 
1209
1785
  function normalizeResolved<Action extends string>(
@@ -1246,9 +1822,9 @@ function normalizeResolved<Action extends string>(
1246
1822
  if (!Number.isFinite(expiry) || expiry <= now) return null;
1247
1823
  }
1248
1824
  const source = boundedMetadata(value.source);
1249
- const campaignId = boundedMetadata(value.campaignId);
1250
- const experimentId = boundedMetadata(value.experimentId);
1251
- const variantId = boundedMetadata(value.variantId);
1825
+ const campaignId = boundedMetadata(value.campaignId, 128);
1826
+ const experimentId = boundedMetadata(value.experimentId, 128);
1827
+ const variantId = boundedMetadata(value.variantId, 128);
1252
1828
  return {
1253
1829
  id,
1254
1830
  ...(action ? { action } : {}),
@@ -1286,6 +1862,15 @@ function normalizeLocale(locale: string): string {
1286
1862
  return locale.trim().replace(/_/g, '-');
1287
1863
  }
1288
1864
 
1865
+ function firstOpenNoRouteReason(response: Response): AnonymousFirstOpenTerminalReason {
1866
+ let raw = '';
1867
+ try { raw = response.headers?.get('X-Encore-Match-Outcome')?.trim().toLowerCase() ?? ''; }
1868
+ catch { raw = ''; }
1869
+ return NO_ROUTE_REASONS.has(raw as AnonymousFirstOpenTerminalReason)
1870
+ ? raw as AnonymousFirstOpenTerminalReason
1871
+ : 'unmatched';
1872
+ }
1873
+
1289
1874
  function normalizeAnonymousFirstOpenSignals(
1290
1875
  context: AnonymousFirstOpenContext,
1291
1876
  ): Partial<Omit<AnonymousFirstOpenContext, 'appBundleId' | 'locale' | 'platform' | 'installedAt'>> {
@@ -1397,6 +1982,55 @@ async function withTimeout(
1397
1982
  }
1398
1983
  }
1399
1984
 
1985
+ async function withPromiseTimeout<T>(
1986
+ factory: () => Promise<T>,
1987
+ timeoutMs: number,
1988
+ operation: 'account claim' | 'account pending',
1989
+ ): Promise<T> {
1990
+ let timer: ReturnType<typeof setTimeout> | null = null;
1991
+ try {
1992
+ return await Promise.race([
1993
+ Promise.resolve().then(factory),
1994
+ new Promise<T>((_resolve, reject) => {
1995
+ timer = setTimeout(
1996
+ () => reject(new Error(`Pulse Links: ${operation} timed out`)),
1997
+ timeoutMs,
1998
+ );
1999
+ }),
2000
+ ]);
2001
+ } finally {
2002
+ if (timer) clearTimeout(timer);
2003
+ }
2004
+ }
2005
+
2006
+ async function withTerminalDeliveryTimeout(
2007
+ acknowledgement: Promise<AnonymousFirstOpenTerminalDisposition | boolean>,
2008
+ timeoutMs: number,
2009
+ ): Promise<AnonymousFirstOpenTerminalDisposition | boolean> {
2010
+ let timer: ReturnType<typeof setTimeout> | null = null;
2011
+ try {
2012
+ return await Promise.race([
2013
+ acknowledgement,
2014
+ new Promise<AnonymousFirstOpenTerminalDisposition | boolean>((_resolve, reject) => {
2015
+ timer = setTimeout(
2016
+ () => reject(new Error('Pulse Links: terminal delivery acknowledgement timed out')),
2017
+ timeoutMs,
2018
+ );
2019
+ }),
2020
+ ]);
2021
+ } finally {
2022
+ if (timer) clearTimeout(timer);
2023
+ }
2024
+ }
2025
+
2026
+ function normalizeTerminalDeliveryDisposition(
2027
+ value: AnonymousFirstOpenTerminalDisposition | boolean,
2028
+ ): AnonymousFirstOpenTerminalDisposition {
2029
+ if (value === true || value === 'accepted') return 'accepted';
2030
+ if (value === 'drop') return 'drop';
2031
+ return 'retry';
2032
+ }
2033
+
1400
2034
  async function postResolverOutcome(
1401
2035
  fetcher: typeof fetch,
1402
2036
  resolverBaseUrl: string,
@@ -1488,6 +2122,117 @@ function normalizeOccurredAt(value: unknown): string | null {
1488
2122
  return new Date(timestamp).toISOString() === value ? value : null;
1489
2123
  }
1490
2124
 
2125
+ function readTerminalDelivery(raw: unknown): PersistedFirstOpenTerminalDelivery | null {
2126
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
2127
+ const value = raw as Record<string, unknown>;
2128
+ const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId)
2129
+ ? value.eventId.toLowerCase()
2130
+ : null;
2131
+ const status = value.status === 'FOUND' || value.status === 'NOT_FOUND' || value.status === 'FAILURE'
2132
+ ? value.status
2133
+ : null;
2134
+ const rail = value.rail === 'fast_route' || value.rail === 'no_route' ? value.rail : null;
2135
+ const reason = typeof value.reason === 'string'
2136
+ ? value.reason as AnonymousFirstOpenTerminalReason
2137
+ : null;
2138
+ const occurredAt = normalizeOccurredAt(value.occurredAt);
2139
+ const attempts = typeof value.attempts === 'number'
2140
+ && Number.isSafeInteger(value.attempts)
2141
+ && value.attempts >= 0
2142
+ && value.attempts <= 100_000
2143
+ ? value.attempts
2144
+ : null;
2145
+ const nextRetryAt = typeof value.nextRetryAt === 'number'
2146
+ && Number.isSafeInteger(value.nextRetryAt)
2147
+ && value.nextRetryAt >= 0
2148
+ && value.nextRetryAt <= 8_640_000_000_000_000
2149
+ ? value.nextRetryAt
2150
+ : null;
2151
+ if (value.disposition === 'drop') {
2152
+ const allowedKeys = new Set(['eventId', 'disposition', 'attempts', 'nextRetryAt']);
2153
+ if (!eventId
2154
+ || attempts === null
2155
+ || nextRetryAt === null
2156
+ || Object.keys(value).some((key) => !allowedKeys.has(key))) return null;
2157
+ return { eventId, disposition: 'drop', attempts, nextRetryAt };
2158
+ }
2159
+ const matchBasis = typeof value.matchBasis === 'string'
2160
+ && MATCH_BASES.has(value.matchBasis as DeferredLinkMatchBasis)
2161
+ ? value.matchBasis as DeferredLinkMatchBasis
2162
+ : null;
2163
+ const confidence = typeof value.confidence === 'number'
2164
+ && Number.isFinite(value.confidence)
2165
+ && value.confidence >= 0
2166
+ && value.confidence <= 1
2167
+ ? value.confidence
2168
+ : null;
2169
+ const metadata: Partial<Pick<
2170
+ AnonymousFirstOpenTerminalOutcome,
2171
+ 'campaignId' | 'experimentId' | 'variantId'
2172
+ >> = {};
2173
+ let metadataValid = true;
2174
+ for (const key of ['campaignId', 'experimentId', 'variantId'] as const) {
2175
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
2176
+ const field = value[key];
2177
+ if (field === null) metadata[key] = null;
2178
+ else if (typeof field === 'string'
2179
+ && field.length > 0
2180
+ && field.length <= 128
2181
+ && field.trim() === field) metadata[key] = field;
2182
+ else metadataValid = false;
2183
+ }
2184
+ const hasFoundDimensions = Object.prototype.hasOwnProperty.call(value, 'matchBasis')
2185
+ && Object.prototype.hasOwnProperty.call(value, 'confidence');
2186
+ const hasAnyAttributionDimension = Object.prototype.hasOwnProperty.call(value, 'matchBasis')
2187
+ || Object.prototype.hasOwnProperty.call(value, 'confidence')
2188
+ || Object.prototype.hasOwnProperty.call(value, 'campaignId')
2189
+ || Object.prototype.hasOwnProperty.call(value, 'experimentId')
2190
+ || Object.prototype.hasOwnProperty.call(value, 'variantId');
2191
+ const validSemanticOutcome = status === 'FOUND'
2192
+ ? rail === 'fast_route'
2193
+ && value.routed === true
2194
+ && reason === 'matched'
2195
+ && hasFoundDimensions
2196
+ && matchBasis === 'unique_probabilistic'
2197
+ && confidence !== null
2198
+ && metadataValid
2199
+ : status === 'NOT_FOUND'
2200
+ ? rail === 'no_route' && value.routed === false
2201
+ && reason !== null && NO_ROUTE_REASONS.has(reason)
2202
+ && !hasAnyAttributionDimension
2203
+ : status === 'FAILURE'
2204
+ ? rail === 'no_route' && value.routed === false
2205
+ && reason !== null && FAILURE_REASONS.has(reason)
2206
+ && !hasAnyAttributionDimension
2207
+ : false;
2208
+ if (!eventId
2209
+ || !status
2210
+ || !rail
2211
+ || !reason
2212
+ || !occurredAt
2213
+ || attempts === null
2214
+ || nextRetryAt === null
2215
+ || Object.prototype.hasOwnProperty.call(value, 'disposition')
2216
+ || value.retryable !== false
2217
+ || !validSemanticOutcome) return null;
2218
+ return {
2219
+ eventId,
2220
+ status,
2221
+ rail,
2222
+ routed: value.routed as boolean,
2223
+ retryable: false,
2224
+ reason,
2225
+ occurredAt,
2226
+ ...(status === 'FOUND' ? {
2227
+ matchBasis: matchBasis as DeferredLinkMatchBasis,
2228
+ confidence: confidence as number,
2229
+ ...metadata,
2230
+ } : {}),
2231
+ attempts,
2232
+ nextRetryAt,
2233
+ };
2234
+ }
2235
+
1491
2236
  function readState(storage: ConfigStorage | undefined, key: string): PersistedDeferredLinkState {
1492
2237
  if (!storage) return emptyState();
1493
2238
  try {
@@ -1535,6 +2280,7 @@ function readState(storage: ConfigStorage | undefined, key: string): PersistedDe
1535
2280
  .slice(-MAX_NOTIFIED_OUTCOMES)
1536
2281
  : [];
1537
2282
  const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
2283
+ const terminalDelivery = readTerminalDelivery(parsed.terminalDelivery);
1538
2284
  return {
1539
2285
  version: 1,
1540
2286
  status: status === 'resolving' ? (pending ? 'pending' : 'idle') : status,
@@ -1546,6 +2292,7 @@ function readState(storage: ConfigStorage | undefined, key: string): PersistedDe
1546
2292
  ...outcomeQueue.map((outcome) => outcome.transitionKey),
1547
2293
  ])].slice(-MAX_NOTIFIED_OUTCOMES),
1548
2294
  outcomeQueue,
2295
+ terminalDelivery,
1549
2296
  firstOpen: {
1550
2297
  installAttemptId: attemptId,
1551
2298
  completed: firstValue.completed === true,
@@ -1563,9 +2310,32 @@ function writeState(
1563
2310
  storage: ConfigStorage | undefined,
1564
2311
  key: string,
1565
2312
  state: PersistedDeferredLinkState,
1566
- ): void {
1567
- if (!storage) return;
1568
- try { void storage.set(key, JSON.stringify(state)); } catch { /* in-memory operation continues */ }
2313
+ allowKnownAsyncBestEffort = true,
2314
+ ): boolean {
2315
+ if (!storage) return true;
2316
+ const knownAsync = storage.supportsDurableSyncWrites === false
2317
+ || ASYNCHRONOUS_STORAGE_ADAPTERS.has(storage as object);
2318
+ if (knownAsync && !allowKnownAsyncBestEffort) return false;
2319
+ try {
2320
+ const result = (storage.set as unknown as (storageKey: string, value: string) => unknown)(
2321
+ key,
2322
+ JSON.stringify(state),
2323
+ );
2324
+ if (isThenable(result)) {
2325
+ ASYNCHRONOUS_STORAGE_ADAPTERS.add(storage as object);
2326
+ void Promise.resolve(result).catch(() => undefined);
2327
+ return false;
2328
+ }
2329
+ return !knownAsync;
2330
+ } catch {
2331
+ return false;
2332
+ }
2333
+ }
2334
+
2335
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
2336
+ return (typeof value === 'object' && value !== null) || typeof value === 'function'
2337
+ ? typeof (value as { then?: unknown }).then === 'function'
2338
+ : false;
1569
2339
  }
1570
2340
 
1571
2341
  function safeTimestamp(value: unknown): number {