pulse-updates 1.3.7 → 1.3.9

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
@@ -122,6 +122,14 @@ export interface AnonymousFirstOpenContext {
122
122
  /** JavaScript Date#getTimezoneOffset semantics, bounded to real-world UTC offsets. */
123
123
  timezoneOffsetMinutes?: number;
124
124
  isEmulator?: boolean;
125
+ /**
126
+ * The address the person is signed in with, when the host app has one. An install that arrives
127
+ * with an account does not need to be guessed at: the resolver binds it to the message sent to
128
+ * that address and skips probabilistic matching entirely. Omitted for signed-out first opens,
129
+ * and never read from the clipboard or any other ambient source — the app passes what it
130
+ * already knows, or nothing.
131
+ */
132
+ accountEmail?: string;
125
133
  }
126
134
 
127
135
  export type AnonymousFirstOpenResult =
@@ -134,6 +142,51 @@ export type AnonymousFirstOpenResult =
134
142
  | 'retry_scheduled'
135
143
  | 'terminal_error';
136
144
 
145
+ export type AnonymousFirstOpenTerminalStatus = 'FOUND' | 'NOT_FOUND' | 'FAILURE';
146
+
147
+ /** Explicit receiver decision for one terminal-delivery attempt. */
148
+ export type AnonymousFirstOpenTerminalDisposition = 'accepted' | 'retry' | 'drop';
149
+
150
+ export type AnonymousFirstOpenTerminalReason =
151
+ | 'matched'
152
+ | 'unmatched'
153
+ | 'ambiguous'
154
+ | 'holdout'
155
+ | 'low_confidence'
156
+ | 'shadow_would_route'
157
+ | 'shadow_attributed'
158
+ | 'analytics_attributed'
159
+ | 'target_revoked'
160
+ | 'disabled'
161
+ | 'expired_replay'
162
+ | 'invalid_request'
163
+ | 'invalid_response'
164
+ | 'policy_rejected';
165
+
166
+ /**
167
+ * Sanitized terminal result of the anonymous first-open rail. It deliberately carries no
168
+ * install-attempt id, token, device signal or raw server error. A 204 is always `no_route` and
169
+ * can never make the client navigate, including when Encore reports a shadow observation.
170
+ */
171
+ export interface AnonymousFirstOpenTerminalOutcome {
172
+ /** Stable UUIDv4 for receiver-side deduplication of at-least-once delivery. */
173
+ eventId: string;
174
+ status: AnonymousFirstOpenTerminalStatus;
175
+ rail: 'fast_route' | 'no_route';
176
+ routed: boolean;
177
+ retryable: false;
178
+ reason: AnonymousFirstOpenTerminalReason;
179
+ occurredAt: string;
180
+ /** Present only for FOUND and copied from the already-normalized resolver result. */
181
+ matchBasis?: DeferredLinkMatchBasis;
182
+ /** Present only for FOUND, finite and clamped to [0, 1]. */
183
+ confidence?: number;
184
+ /** Optional FOUND-only campaign dimensions; strings are trimmed and bounded to 128 chars. */
185
+ campaignId?: string | null;
186
+ experimentId?: string | null;
187
+ variantId?: string | null;
188
+ }
189
+
137
190
  export type AndroidInstallReferrerStatus =
138
191
  | 'OK'
139
192
  | 'NO_TOKEN'
@@ -163,7 +216,13 @@ export interface DeferredLinkClientOptions<Action extends string = DeferredLinkA
163
216
  allowedActions?: readonly Action[];
164
217
  /** Actions that require both a deterministic match and a stable signed-in account. */
165
218
  sensitiveActions?: readonly Action[];
219
+ /**
220
+ * Routing callbacks are irreversible commit boundaries: return `false` when no navigation
221
+ * happened. If a newer accepted capture arrives while a successful callback is pending, Pulse
222
+ * persists it but will not auto-route a second journey in the same client lifecycle.
223
+ */
166
224
  onDeepLink: (deepLink: string, link: ResolvedDeferredLink<Action>) => boolean | void | Promise<boolean | void>;
225
+ /** Same commit-boundary contract as `onDeepLink`. */
167
226
  onAction?: (link: ResolvedDeferredLink<Action>) => boolean | void | Promise<boolean | void>;
168
227
  accountBridge?: DeferredLinkAccountBridge<Action>;
169
228
  isAccountReady?: () => boolean;
@@ -179,11 +238,31 @@ export interface DeferredLinkClientOptions<Action extends string = DeferredLinkA
179
238
  requestTimeoutMs?: number;
180
239
  retryBaseMs?: number;
181
240
  retryMaxMs?: number;
241
+ /** Timeout for one terminal-result acknowledgement attempt. Defaults to requestTimeoutMs. */
242
+ terminalDeliveryTimeoutMs?: number;
182
243
  tokenMaxAgeMs?: number;
183
244
  recentInstallMaxAgeMs?: number;
184
245
  autoRetry?: boolean;
185
246
  reportResolverOutcomes?: boolean;
186
247
  onOutcome?: (event: DeferredLinkOutcomeEvent) => void | Promise<void>;
248
+ /**
249
+ * Synchronous privacy gate evaluated before a terminal record or eventId is created. A false
250
+ * result or throw completes first-open matching without retaining terminal metadata.
251
+ */
252
+ shouldQueueFirstOpenResult?: () => boolean;
253
+ /**
254
+ * Durable at-least-once terminal delivery. Return `accepted` (or legacy `true`) only after the
255
+ * stable eventId is durably accepted, `retry`/false to replay it, or `drop` to deliberately and
256
+ * durably discard it (for example after consent revocation). Throw, rejection and timeout retry
257
+ * the same eventId. `drop` is sticky: failed local deletion retries never re-enter the receiver.
258
+ * Receivers must deduplicate because a crash after acceptance can replay it. When this handler
259
+ * is absent no terminal record is created or retained.
260
+ */
261
+ onFirstOpenResult?: (
262
+ outcome: AnonymousFirstOpenTerminalOutcome,
263
+ ) => AnonymousFirstOpenTerminalDisposition
264
+ | boolean
265
+ | Promise<AnonymousFirstOpenTerminalDisposition | boolean>;
187
266
  onStateChange?: (state: DeferredLinkState) => void;
188
267
  onError?: (error: unknown) => void;
189
268
  now?: () => number;
@@ -255,6 +334,29 @@ const DEFAULT_ACTIONS = [
255
334
  'refresh_entitlement',
256
335
  ] as const;
257
336
  const DEFAULT_SENSITIVE_ACTIONS = ['manage_subscription', 'refresh_entitlement'] as const;
337
+ const PROBABILISTIC_ACTIONS = new Set<string>(['open_home', 'open_premium']);
338
+ const CAPTURE_PRIORITY: Readonly<Record<DeferredLinkCaptureBasis, number>> = {
339
+ direct_token: 3,
340
+ ios_user_paste: 2,
341
+ android_install_referrer: 1,
342
+ };
343
+ const NO_ROUTE_REASONS = new Set<AnonymousFirstOpenTerminalReason>([
344
+ 'unmatched',
345
+ 'ambiguous',
346
+ 'holdout',
347
+ 'low_confidence',
348
+ 'shadow_would_route',
349
+ 'shadow_attributed',
350
+ 'analytics_attributed',
351
+ 'target_revoked',
352
+ 'disabled',
353
+ 'expired_replay',
354
+ ]);
355
+ const FAILURE_REASONS = new Set<AnonymousFirstOpenTerminalReason>([
356
+ 'invalid_request',
357
+ 'invalid_response',
358
+ 'policy_rejected',
359
+ ]);
258
360
  const DEFAULT_STORAGE_KEY = 'pulse.links.v1';
259
361
  const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
260
362
  const DEFAULT_RETRY_BASE_MS = 15_000;
@@ -265,6 +367,7 @@ const MAX_APPLIED_IDS = 32;
265
367
  const MAX_NOTIFIED_OUTCOMES = MAX_APPLIED_IDS * 3;
266
368
  const MAX_RESOLVER_OUTCOMES = MAX_NOTIFIED_OUTCOMES;
267
369
  const MAX_PERSISTED_BYTES = 131_072;
370
+ const ASYNCHRONOUS_STORAGE_ADAPTERS = new WeakSet<object>();
268
371
  const VERSION_SIGNAL = /^[A-Za-z0-9][A-Za-z0-9._+()-]*$/;
269
372
  const DEVICE_MODEL_CODE = /^[A-Za-z0-9][A-Za-z0-9._,+-]*$/;
270
373
  const DISTRIBUTION_SIGNAL = /^[a-z0-9][a-z0-9._-]*$/;
@@ -293,6 +396,24 @@ interface QueuedResolverOutcome {
293
396
  nextRetryAt: number;
294
397
  }
295
398
 
399
+ interface QueuedFirstOpenTerminalDelivery extends AnonymousFirstOpenTerminalOutcome {
400
+ disposition?: never;
401
+ attempts: number;
402
+ nextRetryAt: number;
403
+ }
404
+
405
+ /** Minimal sticky privacy tombstone. It carries no terminal outcome or attribution metadata. */
406
+ interface DroppedFirstOpenTerminalTombstone {
407
+ eventId: string;
408
+ disposition: 'drop';
409
+ attempts: number;
410
+ nextRetryAt: number;
411
+ }
412
+
413
+ type PersistedFirstOpenTerminalDelivery =
414
+ | QueuedFirstOpenTerminalDelivery
415
+ | DroppedFirstOpenTerminalTombstone;
416
+
296
417
  interface PersistedDeferredLinkState {
297
418
  version: 1;
298
419
  status: DeferredLinkStatus;
@@ -303,6 +424,8 @@ interface PersistedDeferredLinkState {
303
424
  notifiedOutcomes: string[];
304
425
  /** Durable Encore delivery queue. Exposure tokens stay local and are used only in URL paths. */
305
426
  outcomeQueue: QueuedResolverOutcome[];
427
+ /** One-record at-least-once application delivery outbox, independent of matcher completion. */
428
+ terminalDelivery: PersistedFirstOpenTerminalDelivery | null;
306
429
  firstOpen: DeferredLinkFirstOpenState;
307
430
  }
308
431
 
@@ -319,6 +442,7 @@ const emptyState = (): PersistedDeferredLinkState => ({
319
442
  appliedIds: [],
320
443
  notifiedOutcomes: [],
321
444
  outcomeQueue: [],
445
+ terminalDelivery: null,
322
446
  firstOpen: {
323
447
  installAttemptId: null,
324
448
  completed: false,
@@ -430,6 +554,12 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
430
554
  }
431
555
  const storageKey = explicitStorageKey || (appSlug ? `pulse.${appSlug}.links.v1` : DEFAULT_STORAGE_KEY);
432
556
  const requestTimeoutMs = boundedDuration(options.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS, 500, 60_000);
557
+ const terminalDeliveryTimeoutMs = boundedDuration(
558
+ options.terminalDeliveryTimeoutMs,
559
+ requestTimeoutMs,
560
+ 100,
561
+ 60_000,
562
+ );
433
563
  const retryBaseMs = boundedDuration(options.retryBaseMs, DEFAULT_RETRY_BASE_MS, 100, 60 * 60 * 1_000);
434
564
  const retryMaxMs = boundedDuration(options.retryMaxMs, DEFAULT_RETRY_MAX_MS, retryBaseMs, 24 * 60 * 60 * 1_000);
435
565
  const tokenMaxAgeMs = boundedDuration(options.tokenMaxAgeMs, DEFAULT_TOKEN_MAX_AGE_MS, 60_000, 365 * 24 * 60 * 60 * 1_000);
@@ -443,11 +573,26 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
443
573
  let state = readState(options.storage, storageKey);
444
574
  let disposed = false;
445
575
  let processing: Promise<void> | null = null;
576
+ let firstOpenMatching: Promise<AnonymousFirstOpenResult> | null = null;
446
577
  let processRequested = false;
447
578
  let retryTimer: ReturnType<typeof setTimeout> | null = null;
448
579
  let outcomeSending: Promise<number> | null = null;
580
+ let terminalDeliverySending: Promise<boolean> | null = null;
581
+ let terminalDeliveryGeneration = 0;
449
582
  let accountRetryAt = 0;
450
583
  let accountAttempts = 0;
584
+ let deterministicEpoch = 0;
585
+ // Invalidates every host callback that crossed an await when this client is reset/disposed.
586
+ // This is deliberately separate from deterministicEpoch: captures during a committed callback
587
+ // have intentional last-touch semantics, whereas a lifecycle boundary must forbid every stale
588
+ // state write, retry and outcome from the old instance.
589
+ let lifecycleGeneration = 0;
590
+ // Host navigation is irreversible once any routing callback has started: it may perform its
591
+ // side effect synchronously before returning a Promise. Serialize captures against that commit
592
+ // boundary and keep the newer accepted token durable for the next client lifecycle instead of
593
+ // automatically opening a second journey behind the first one.
594
+ let routingApplicationInFlight = false;
595
+ let deterministicTokenDeferredAfterCommittedJourney: string | null = null;
451
596
  let lastFirstOpenContext: AnonymousFirstOpenContext | null = null;
452
597
  let unsubscribeAccount: (() => void) | null = null;
453
598
  const listeners = new Set<(value: DeferredLinkState) => void>();
@@ -456,6 +601,24 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
456
601
  try { options.onError?.(error); } catch { /* diagnostic hooks never break link handling */ }
457
602
  };
458
603
 
604
+ // A build without a receiver has not opted into retaining this analytics envelope. First replace
605
+ // an old full record with the same minimal sticky tombstone used by an explicit drop. Deletion
606
+ // may fail, but a later app version can then only retry local deletion, never resurrect delivery.
607
+ if (!options.onFirstOpenResult
608
+ && state.terminalDelivery
609
+ && state.terminalDelivery.disposition !== 'drop') {
610
+ const tombstone: DroppedFirstOpenTerminalTombstone = {
611
+ eventId: state.terminalDelivery.eventId,
612
+ disposition: 'drop',
613
+ attempts: state.terminalDelivery.attempts,
614
+ nextRetryAt: 0,
615
+ };
616
+ state = { ...state, terminalDelivery: tombstone };
617
+ if (!writeState(options.storage, storageKey, state)) {
618
+ reportError(new Error('Pulse Links: stale terminal delivery tombstone was not persisted'));
619
+ }
620
+ }
621
+
459
622
  const snapshot = (): DeferredLinkState => ({
460
623
  status: state.status,
461
624
  pending: state.pending ? { ...state.pending } : null,
@@ -464,8 +627,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
464
627
  firstOpen: { ...state.firstOpen },
465
628
  });
466
629
 
467
- const persistAndNotify = (): void => {
468
- writeState(options.storage, storageKey, state);
630
+ const notifyState = (): void => {
469
631
  const value = snapshot();
470
632
  for (const listener of listeners) {
471
633
  try { listener(value); } catch (error) { reportError(error); }
@@ -474,6 +636,12 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
474
636
  scheduleWake();
475
637
  };
476
638
 
639
+ const persistAndNotify = (): boolean => {
640
+ const persisted = writeState(options.storage, storageKey, state);
641
+ notifyState();
642
+ return persisted;
643
+ };
644
+
477
645
  const patchState = (patch: Partial<PersistedDeferredLinkState>): void => {
478
646
  state = { ...state, ...patch };
479
647
  persistAndNotify();
@@ -484,11 +652,257 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
484
652
  persistAndNotify();
485
653
  };
486
654
 
487
- const isAccountReady = (): boolean => {
488
- try { return options.isAccountReady?.() ?? false; } catch (error) {
489
- reportError(error);
655
+ const terminalDeliveryOutcome = (
656
+ queued: QueuedFirstOpenTerminalDelivery,
657
+ ): AnonymousFirstOpenTerminalOutcome => ({
658
+ eventId: queued.eventId,
659
+ status: queued.status,
660
+ rail: queued.rail,
661
+ routed: queued.routed,
662
+ retryable: false,
663
+ reason: queued.reason,
664
+ occurredAt: queued.occurredAt,
665
+ ...(queued.matchBasis !== undefined ? { matchBasis: queued.matchBasis } : {}),
666
+ ...(queued.confidence !== undefined ? { confidence: queued.confidence } : {}),
667
+ ...(queued.campaignId !== undefined ? { campaignId: queued.campaignId } : {}),
668
+ ...(queued.experimentId !== undefined ? { experimentId: queued.experimentId } : {}),
669
+ ...(queued.variantId !== undefined ? { variantId: queued.variantId } : {}),
670
+ });
671
+
672
+ const scheduleTerminalDeliveryRetry = (
673
+ queued: PersistedFirstOpenTerminalDelivery,
674
+ ): void => {
675
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return;
676
+ const attempts = Math.min(queued.attempts + 1, 100_000);
677
+ state = {
678
+ ...state,
679
+ terminalDelivery: {
680
+ ...queued,
681
+ attempts,
682
+ nextRetryAt: now() + resolverOutcomeRetryDelay(
683
+ queued.eventId,
684
+ attempts,
685
+ retryBaseMs,
686
+ retryMaxMs,
687
+ ),
688
+ },
689
+ };
690
+ persistAndNotify();
691
+ };
692
+
693
+ const acknowledgeTerminalDelivery = (
694
+ queued: PersistedFirstOpenTerminalDelivery,
695
+ ): boolean => {
696
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
697
+ const acknowledgedState: PersistedDeferredLinkState = {
698
+ ...state,
699
+ terminalDelivery: null,
700
+ };
701
+ // Clearing the record is itself transactional. If this write fails after the receiver
702
+ // accepted the event, retain and replay the same eventId: that is why the contract is
703
+ // at-least-once and why receivers must deduplicate.
704
+ if (!writeState(options.storage, storageKey, acknowledgedState, false)) {
705
+ reportError(new Error('Pulse Links: terminal delivery acknowledgement was not persisted'));
706
+ scheduleTerminalDeliveryRetry(queued);
707
+ return false;
708
+ }
709
+ state = acknowledgedState;
710
+ notifyState();
711
+ return true;
712
+ };
713
+
714
+ const flushTerminalDelivery = (): Promise<boolean> => {
715
+ if (disposed) return Promise.resolve(false);
716
+ if (terminalDeliverySending) return terminalDeliverySending;
717
+ const queued = state.terminalDelivery;
718
+ if (!queued || queued.nextRetryAt > now()) return Promise.resolve(false);
719
+ if (queued.disposition === 'drop') {
720
+ return Promise.resolve(acknowledgeTerminalDelivery(queued));
721
+ }
722
+ if (!options.onFirstOpenResult) return Promise.resolve(false);
723
+ const generation = terminalDeliveryGeneration;
724
+
725
+ const run = async (): Promise<boolean> => {
726
+ // Never call the receiver before the complete record is durable. With no storage adapter,
727
+ // this is an explicitly memory-only degradation rather than a cross-restart guarantee.
728
+ if (!writeState(options.storage, storageKey, state)) {
729
+ reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
730
+ scheduleTerminalDeliveryRetry(queued);
731
+ return false;
732
+ }
733
+
734
+ let rawDisposition:
735
+ | AnonymousFirstOpenTerminalDisposition
736
+ | boolean
737
+ | Promise<AnonymousFirstOpenTerminalDisposition | boolean>;
738
+ try {
739
+ rawDisposition = options.onFirstOpenResult!(terminalDeliveryOutcome(queued));
740
+ } catch (error) {
741
+ reportError(error);
742
+ scheduleTerminalDeliveryRetry(queued);
743
+ return false;
744
+ }
745
+
746
+ let disposition: AnonymousFirstOpenTerminalDisposition;
747
+ if (typeof rawDisposition === 'boolean' || typeof rawDisposition === 'string') {
748
+ disposition = normalizeTerminalDeliveryDisposition(rawDisposition);
749
+ } else {
750
+ try {
751
+ const resolved = await withTerminalDeliveryTimeout(
752
+ Promise.resolve(rawDisposition),
753
+ terminalDeliveryTimeoutMs,
754
+ );
755
+ disposition = normalizeTerminalDeliveryDisposition(resolved);
756
+ } catch (error) {
757
+ if (!disposed && terminalDeliveryGeneration === generation) reportError(error);
758
+ if (!disposed && terminalDeliveryGeneration === generation) {
759
+ scheduleTerminalDeliveryRetry(queued);
760
+ }
761
+ return false;
762
+ }
763
+ }
764
+
765
+ if (disposed
766
+ || terminalDeliveryGeneration !== generation
767
+ || state.terminalDelivery?.eventId !== queued.eventId) return false;
768
+ if (disposition === 'accepted') return acknowledgeTerminalDelivery(queued);
769
+ if (disposition === 'drop') return dropTerminalDelivery(queued);
770
+ scheduleTerminalDeliveryRetry(queued);
771
+ return false;
772
+ };
773
+
774
+ let owned: Promise<boolean>;
775
+ owned = run().finally(() => {
776
+ if (terminalDeliverySending === owned) terminalDeliverySending = null;
777
+ const pending = state.terminalDelivery;
778
+ if (!disposed && options.onFirstOpenResult && pending && pending.nextRetryAt <= now()) {
779
+ void flushTerminalDelivery();
780
+ }
781
+ });
782
+ terminalDeliverySending = owned;
783
+ return owned;
784
+ };
785
+
786
+ const dropTerminalDelivery = (
787
+ queued: QueuedFirstOpenTerminalDelivery,
788
+ ): boolean => {
789
+ if (disposed || state.terminalDelivery?.eventId !== queued.eventId) return false;
790
+ const tombstone: DroppedFirstOpenTerminalTombstone = {
791
+ eventId: queued.eventId,
792
+ disposition: 'drop',
793
+ attempts: queued.attempts,
794
+ nextRetryAt: 0,
795
+ };
796
+ state = { ...state, terminalDelivery: tombstone };
797
+ // Persist the sticky disposition before trying to delete it. A failed local deletion can then
798
+ // retry only deletion after restart; it must never call the receiver or resurrect collection.
799
+ if (!writeState(options.storage, storageKey, state)) {
800
+ reportError(new Error('Pulse Links: terminal drop tombstone was not persisted'));
801
+ scheduleTerminalDeliveryRetry(tombstone);
490
802
  return false;
491
803
  }
804
+ notifyState();
805
+ return acknowledgeTerminalDelivery(tombstone);
806
+ };
807
+
808
+ const completeFirstOpen = (
809
+ status: DeferredLinkStatus,
810
+ outcome: Omit<AnonymousFirstOpenTerminalOutcome, 'eventId' | 'occurredAt' | 'retryable'>,
811
+ ): void => {
812
+ const completionLifecycleGeneration = lifecycleGeneration;
813
+ const completionDeterministicEpoch = deterministicEpoch;
814
+ const completionIsCurrent = (): boolean => !disposed
815
+ && lifecycleGeneration === completionLifecycleGeneration
816
+ && deterministicEpoch === completionDeterministicEpoch;
817
+ let shouldQueue = Boolean(options.onFirstOpenResult);
818
+ if (shouldQueue && options.shouldQueueFirstOpenResult) {
819
+ try {
820
+ shouldQueue = options.shouldQueueFirstOpenResult() === true;
821
+ } catch (error) {
822
+ shouldQueue = false;
823
+ reportError(error);
824
+ }
825
+ // Privacy/diagnostic hooks are host code and may synchronously reset, dispose, or capture a
826
+ // deterministic destination. Never resurrect the terminal first-open state they invalidated.
827
+ if (!completionIsCurrent()) return;
828
+ }
829
+ if (!shouldQueue) {
830
+ state = {
831
+ ...state,
832
+ status,
833
+ terminalDelivery: null,
834
+ firstOpen: {
835
+ ...state.firstOpen,
836
+ completed: true,
837
+ attempts: 0,
838
+ nextRetryAt: 0,
839
+ },
840
+ };
841
+ persistAndNotify();
842
+ return;
843
+ }
844
+
845
+ const existingEventIds = new Set<string>([
846
+ ...state.outcomeQueue.map((queued) => queued.eventId),
847
+ ...(state.firstOpen.installAttemptId ? [state.firstOpen.installAttemptId] : []),
848
+ ...(state.terminalDelivery ? [state.terminalDelivery.eventId] : []),
849
+ ]);
850
+ const delivery = state.terminalDelivery ?? {
851
+ ...outcome,
852
+ eventId: makeOutcomeEventId(options.randomUUID, existingEventIds),
853
+ retryable: false as const,
854
+ occurredAt: new Date(now()).toISOString(),
855
+ attempts: 0,
856
+ nextRetryAt: 0,
857
+ };
858
+ state = {
859
+ ...state,
860
+ status,
861
+ terminalDelivery: delivery,
862
+ firstOpen: {
863
+ ...state.firstOpen,
864
+ completed: true,
865
+ attempts: 0,
866
+ nextRetryAt: 0,
867
+ },
868
+ };
869
+ if (!persistAndNotify()) {
870
+ reportError(new Error('Pulse Links: terminal delivery outbox was not persisted'));
871
+ }
872
+ // Delivery is deliberately detached from routing/matcher completion.
873
+ void flushTerminalDelivery();
874
+ };
875
+
876
+ const blockFirstOpenForDeterministic = (): boolean => {
877
+ const blockingLifecycleGeneration = lifecycleGeneration;
878
+ deterministicEpoch += 1;
879
+ const blockingDeterministicEpoch = deterministicEpoch;
880
+ if (state.firstOpen.completed && state.firstOpen.nextRetryAt === 0) return true;
881
+ state = {
882
+ ...state,
883
+ firstOpen: { ...state.firstOpen, completed: true, nextRetryAt: 0 },
884
+ };
885
+ persistAndNotify();
886
+ return !disposed
887
+ && lifecycleGeneration === blockingLifecycleGeneration
888
+ && deterministicEpoch === blockingDeterministicEpoch;
889
+ };
890
+
891
+ const readAccountReadiness = (): { ready: boolean; current: boolean } => {
892
+ const readinessLifecycleGeneration = lifecycleGeneration;
893
+ const readinessDeterministicEpoch = deterministicEpoch;
894
+ let ready = false;
895
+ try { ready = options.isAccountReady?.() ?? false; } catch (error) {
896
+ reportError(error);
897
+ }
898
+ return {
899
+ ready,
900
+ // Readiness is host code, not a pure getter. A reset/dispose/capture inside it invalidates
901
+ // the caller's snapshot and must be observed before any waiting/retry/application write.
902
+ current: !disposed
903
+ && lifecycleGeneration === readinessLifecycleGeneration
904
+ && deterministicEpoch === readinessDeterministicEpoch,
905
+ };
492
906
  };
493
907
 
494
908
  const schedulePendingRetry = (): void => {
@@ -723,7 +1137,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
723
1137
  if (!sourceToken || state.pending?.token === sourceToken) clearPendingAsTerminal();
724
1138
  return false;
725
1139
  }
726
- if (!isAccountReady()) {
1140
+ const accountReadiness = readAccountReadiness();
1141
+ if (!accountReadiness.current) return false;
1142
+ if (!accountReadiness.ready) {
727
1143
  patchState({ status: 'waiting_for_account' });
728
1144
  return false;
729
1145
  }
@@ -740,9 +1156,36 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
740
1156
  return false;
741
1157
  }
742
1158
 
1159
+ let applied: boolean | void = false;
1160
+ let applicationFailed = false;
1161
+ let applicationError: unknown;
1162
+ const applicationEpoch = deterministicEpoch;
1163
+ const applicationLifecycleGeneration = lifecycleGeneration;
1164
+ const lifecycleIsCurrent = (): boolean => !disposed
1165
+ && lifecycleGeneration === applicationLifecycleGeneration;
1166
+ const hasNewerPendingIntent = (): boolean => deterministicEpoch !== applicationEpoch
1167
+ && state.pending !== null
1168
+ // A provenance upgrade or rejected lower-priority capture for the token already being
1169
+ // applied does not represent another destination and must not leave that token pending.
1170
+ && (sourceToken === null || state.pending.token !== sourceToken);
1171
+ routingApplicationInFlight = true;
743
1172
  emitOutcome(link, 'app_open_confirmed');
1173
+ if (!lifecycleIsCurrent()) {
1174
+ routingApplicationInFlight = false;
1175
+ return false;
1176
+ }
744
1177
  emitOutcome(link, 'deferred_link_resolved');
745
- let applied: boolean | void;
1178
+ if (!lifecycleIsCurrent()) {
1179
+ routingApplicationInFlight = false;
1180
+ return false;
1181
+ }
1182
+ // `emitOutcome` invokes host diagnostics synchronously. If those hooks re-enter capture with
1183
+ // a newer accepted destination, it still arrived before the routing callback and must win.
1184
+ if (hasNewerPendingIntent()) {
1185
+ routingApplicationInFlight = false;
1186
+ void process();
1187
+ return false;
1188
+ }
746
1189
  try {
747
1190
  if (link.deepLink) {
748
1191
  applied = await options.onDeepLink(link.deepLink, link);
@@ -753,24 +1196,60 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
753
1196
  return false;
754
1197
  }
755
1198
  } catch (error) {
756
- reportError(error);
1199
+ applicationFailed = true;
1200
+ applicationError = error;
1201
+ } finally {
1202
+ routingApplicationInFlight = false;
1203
+ }
1204
+
1205
+ // The callback may resolve after reset/dispose and after another client has already committed
1206
+ // a newer journey into the same storage. The old instance must not schedule a retry, rewrite
1207
+ // state, or emit action_applied regardless of whether the callback returned true/false/threw.
1208
+ if (!lifecycleIsCurrent()) return false;
1209
+
1210
+ const newerIntentArrivedDuringApplication = hasNewerPendingIntent();
1211
+ if (applicationFailed) {
1212
+ reportError(applicationError);
1213
+ if (!lifecycleIsCurrent()) return false;
1214
+ // onError is host code too: it may capture a newer deterministic destination while reporting
1215
+ // this failure. Re-evaluate after the callback instead of arming a stale retry from the value
1216
+ // observed before diagnostics ran.
1217
+ if (hasNewerPendingIntent()) {
1218
+ // The current action did not commit. Resume the newer accepted intent that was captured
1219
+ // while its callback was suspended, without scheduling a retry for the superseded one.
1220
+ void process();
1221
+ return false;
1222
+ }
757
1223
  if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();
758
1224
  else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();
759
1225
  else scheduleAccountRetry();
760
1226
  return false;
761
1227
  }
762
1228
  if (applied === false) {
1229
+ if (newerIntentArrivedDuringApplication) {
1230
+ void process();
1231
+ return false;
1232
+ }
763
1233
  if (sourceToken && state.pending?.token === sourceToken) schedulePendingRetry();
764
1234
  else if (link.matchBasis === 'unique_probabilistic') scheduleFirstOpenRetry();
765
1235
  else scheduleAccountRetry();
766
1236
  return false;
767
1237
  }
768
1238
 
1239
+ if (newerIntentArrivedDuringApplication) {
1240
+ // Invocation of any host routing callback is the last safe commit boundary. A successful
1241
+ // callback may already have navigated, so automatically applying the newly captured token
1242
+ // now would create two journeys. Keep it pending and let a fresh SDK lifecycle recover it.
1243
+ deterministicTokenDeferredAfterCommittedJourney = state.pending!.token;
1244
+ }
1245
+
769
1246
  const newlyApplied = sourceToken && sourceToken !== link.id ? [link.id, sourceToken] : [link.id];
770
1247
  const newlyAppliedSet = new Set(newlyApplied);
771
1248
  const appliedIds = [...state.appliedIds.filter((id) => !newlyAppliedSet.has(id)), ...newlyApplied]
772
1249
  .slice(-MAX_APPLIED_IDS);
773
- const stillPending = sourceToken !== null && state.pending?.token !== sourceToken;
1250
+ const stillPending = sourceToken !== null
1251
+ ? state.pending?.token !== sourceToken
1252
+ : newerIntentArrivedDuringApplication;
774
1253
  state = {
775
1254
  ...state,
776
1255
  status: stillPending ? 'pending' : 'applied',
@@ -779,12 +1258,16 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
779
1258
  appliedIds,
780
1259
  };
781
1260
  persistAndNotify();
1261
+ if (!lifecycleIsCurrent()) return false;
782
1262
  if (link.matchBasis === 'account_bound') {
783
1263
  accountAttempts = 0;
784
1264
  accountRetryAt = 0;
785
1265
  }
786
1266
  emitOutcome(link, 'action_applied');
787
- if (stillPending) void process();
1267
+ if (!lifecycleIsCurrent()) return false;
1268
+ if (stillPending && state.pending?.token !== deterministicTokenDeferredAfterCommittedJourney) {
1269
+ void process();
1270
+ }
788
1271
  return true;
789
1272
  };
790
1273
 
@@ -822,17 +1305,28 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
822
1305
 
823
1306
  const processOnce = async (): Promise<void> => {
824
1307
  if (disposed) return;
1308
+ // `capture()` can re-enter while any host callback is awaiting. Never start another routing
1309
+ // application concurrently, and never auto-drain the exact token preserved after an already
1310
+ // committed journey in this client lifecycle.
1311
+ if (routingApplicationInFlight) return;
825
1312
  const pending = state.pending;
826
1313
  if (pending) {
1314
+ if (pending.token === deterministicTokenDeferredAfterCommittedJourney) return;
827
1315
  if (pending.nextRetryAt > now()) return;
828
1316
  if (now() - pending.receivedAt > tokenMaxAgeMs) {
829
1317
  clearPendingAsTerminal();
830
1318
  return;
831
1319
  }
832
1320
  const token = pending.token;
1321
+ const pendingLifecycleGeneration = lifecycleGeneration;
833
1322
  patchState({ status: 'resolving' });
1323
+ if (disposed
1324
+ || lifecycleGeneration !== pendingLifecycleGeneration
1325
+ || state.pending?.token !== token) return;
834
1326
  const publicResult = await resolvePublic(token, pending.matchBasis);
835
- if (disposed || state.pending?.token !== token) return;
1327
+ if (disposed
1328
+ || lifecycleGeneration !== pendingLifecycleGeneration
1329
+ || state.pending?.token !== token) return;
836
1330
  if (publicResult.kind === 'resolved') {
837
1331
  await applyResolved(publicResult.link, token);
838
1332
  return;
@@ -842,13 +1336,28 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
842
1336
  return;
843
1337
  }
844
1338
 
845
- if (!options.accountBridge || !isAccountReady()) {
1339
+ if (!options.accountBridge) {
1340
+ patchState({ status: 'waiting_for_account' });
1341
+ return;
1342
+ }
1343
+ const accountReadiness = readAccountReadiness();
1344
+ if (!accountReadiness.current
1345
+ || disposed
1346
+ || lifecycleGeneration !== pendingLifecycleGeneration
1347
+ || state.pending?.token !== token) return;
1348
+ if (!accountReadiness.ready) {
846
1349
  patchState({ status: 'waiting_for_account' });
847
1350
  return;
848
1351
  }
849
1352
  try {
850
- const raw = await options.accountBridge.claim(token);
851
- if (disposed || state.pending?.token !== token) return;
1353
+ const raw = await withPromiseTimeout(
1354
+ () => options.accountBridge!.claim(token),
1355
+ requestTimeoutMs,
1356
+ 'account claim',
1357
+ );
1358
+ if (disposed
1359
+ || lifecycleGeneration !== pendingLifecycleGeneration
1360
+ || state.pending?.token !== token) return;
852
1361
  const normalizedClaim = normalizeResolved<Action>(
853
1362
  raw,
854
1363
  token,
@@ -862,18 +1371,42 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
862
1371
  matchGuaranteed: true,
863
1372
  confidence: 1,
864
1373
  } : null;
865
- if (claimed) await applyResolved(claimed, token);
1374
+ if (claimed) {
1375
+ if (!blockFirstOpenForDeterministic()) return;
1376
+ await applyResolved(claimed, token);
1377
+ }
866
1378
  else clearPendingAsTerminal();
867
1379
  } catch (error) {
1380
+ if (disposed
1381
+ || lifecycleGeneration !== pendingLifecycleGeneration
1382
+ || state.pending?.token !== token) return;
868
1383
  reportError(error);
869
- if (state.pending?.token === token) schedulePendingRetry();
1384
+ if (!disposed
1385
+ && lifecycleGeneration === pendingLifecycleGeneration
1386
+ && state.pending?.token === token) schedulePendingRetry();
870
1387
  }
871
1388
  return;
872
1389
  }
873
1390
 
874
- if (!options.accountBridge || !isAccountReady() || accountRetryAt > now()) return;
1391
+ if (!options.accountBridge) return;
1392
+ const accountReadiness = readAccountReadiness();
1393
+ if (!accountReadiness.current || state.pending) return;
1394
+ if (!accountReadiness.ready || accountRetryAt > now()) return;
1395
+ const accountPendingEpoch = deterministicEpoch;
1396
+ const accountPendingLifecycleGeneration = lifecycleGeneration;
875
1397
  try {
876
- const raw = await options.accountBridge.pending();
1398
+ const raw = await withPromiseTimeout(
1399
+ () => options.accountBridge!.pending(),
1400
+ requestTimeoutMs,
1401
+ 'account pending',
1402
+ );
1403
+ // A URL/paste/referrer captured while the account lookup was in flight is newer explicit
1404
+ // user intent. Ignore the stale bridge response and let the requested drain resolve the
1405
+ // captured token; otherwise both deterministic destinations could be applied.
1406
+ if (disposed
1407
+ || lifecycleGeneration !== accountPendingLifecycleGeneration
1408
+ || deterministicEpoch !== accountPendingEpoch
1409
+ || state.pending) return;
877
1410
  if (!raw) {
878
1411
  accountAttempts = 0;
879
1412
  accountRetryAt = 0;
@@ -898,13 +1431,27 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
898
1431
  matchGuaranteed: true,
899
1432
  confidence: 1,
900
1433
  } : 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' });
1434
+ if (pendingLink) {
1435
+ // Reserve the first-open journey for deterministic recovery before invoking any host
1436
+ // callback. This also covers an already-applied account item restored from older SDK
1437
+ // state, which must still prevent a second probabilistic destination.
1438
+ if (!blockFirstOpenForDeterministic()) return;
1439
+ if (!state.appliedIds.includes(pendingLink.id)) {
1440
+ await applyResolved(pendingLink, null);
1441
+ } else if (state.status !== 'applied') {
1442
+ patchState({ status: 'applied' });
1443
+ }
905
1444
  }
906
1445
  } catch (error) {
1446
+ if (disposed
1447
+ || lifecycleGeneration !== accountPendingLifecycleGeneration
1448
+ || deterministicEpoch !== accountPendingEpoch
1449
+ || state.pending) return;
907
1450
  reportError(error);
1451
+ if (disposed
1452
+ || lifecycleGeneration !== accountPendingLifecycleGeneration
1453
+ || deterministicEpoch !== accountPendingEpoch
1454
+ || state.pending) return;
908
1455
  // No token was consumed; the authenticated server outbox remains authoritative.
909
1456
  scheduleAccountRetry();
910
1457
  }
@@ -912,6 +1459,8 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
912
1459
 
913
1460
  const process = async (): Promise<void> => {
914
1461
  if (disposed) return;
1462
+ // Application delivery is independent: never await it on the routing drain.
1463
+ void flushTerminalDelivery();
915
1464
  processRequested = true;
916
1465
  if (processing) return processing;
917
1466
  const run = (async () => {
@@ -938,11 +1487,13 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
938
1487
  const token = normalizeDeferredHandoffToken(rawToken);
939
1488
  if (!token) return false;
940
1489
  if (state.appliedIds.includes(token)) {
1490
+ deterministicEpoch += 1;
941
1491
  if (!state.firstOpen.completed) patchFirstOpen({ completed: true, nextRetryAt: 0 });
942
1492
  return true;
943
1493
  }
944
1494
  if (state.pending?.token === token) {
945
- if (state.pending.matchBasis === 'direct_token' && basis !== 'direct_token') {
1495
+ deterministicEpoch += 1;
1496
+ if (CAPTURE_PRIORITY[basis] > CAPTURE_PRIORITY[state.pending.matchBasis]) {
946
1497
  state = {
947
1498
  ...state,
948
1499
  pending: { ...state.pending, matchBasis: basis },
@@ -955,6 +1506,19 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
955
1506
  void process();
956
1507
  return true;
957
1508
  }
1509
+
1510
+ // Exact transports are deterministic, but when more than one arrives during cold start their
1511
+ // provenance still has an explicit precedence. A lower-priority late callback must not replace
1512
+ // the URL the user intentionally opened; equal priority keeps last-touch behaviour.
1513
+ if (state.pending
1514
+ && CAPTURE_PRIORITY[basis] < CAPTURE_PRIORITY[state.pending.matchBasis]) {
1515
+ deterministicEpoch += 1;
1516
+ if (!state.firstOpen.completed) patchFirstOpen({ completed: true, nextRetryAt: 0 });
1517
+ void process();
1518
+ return true;
1519
+ }
1520
+
1521
+ deterministicEpoch += 1;
958
1522
  state = {
959
1523
  ...state,
960
1524
  status: 'pending',
@@ -976,18 +1540,45 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
976
1540
  return true;
977
1541
  };
978
1542
 
979
- const matchFirstOpen = async (
1543
+ const matchFirstOpenOnce = async (
980
1544
  context: AnonymousFirstOpenContext,
981
1545
  ): Promise<AnonymousFirstOpenResult> => {
982
1546
  if (disposed) return 'ineligible';
983
1547
  lastFirstOpenContext = context;
984
1548
  if (state.pending) return 'deterministic_pending';
985
1549
  if (state.firstOpen.completed) return 'already_completed';
986
- if (state.firstOpen.nextRetryAt > now()) return 'backoff';
1550
+ if (state.firstOpen.nextRetryAt > now()) {
1551
+ // On a fresh client lifecycle scheduleWake() ran before the host supplied this ephemeral
1552
+ // context, so the persisted first-open deadline could not be part of its candidates. Re-arm
1553
+ // now that retrying is possible; otherwise a 503 followed by process death remains asleep
1554
+ // until some unrelated foreground/manual call invokes matchFirstOpen again.
1555
+ scheduleWake();
1556
+ return 'backoff';
1557
+ }
1558
+ if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
1559
+
1560
+ // The client starts account recovery at construction. Join that deterministic rail before
1561
+ // asking the probabilistic matcher; otherwise a slow Billing/account response can navigate
1562
+ // after the probabilistic destination and the user observes both journeys.
1563
+ await process();
1564
+ if (disposed) return 'ineligible';
1565
+ if (state.pending || state.firstOpen.completed) return 'deterministic_pending';
1566
+ if (options.accountBridge) {
1567
+ const accountReadiness = readAccountReadiness();
1568
+ if (!accountReadiness.current) {
1569
+ return disposed ? 'ineligible' : 'deterministic_pending';
1570
+ }
1571
+ if (accountReadiness.ready && accountRetryAt > now()) {
1572
+ if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1573
+ return 'retry_scheduled';
1574
+ }
1575
+ }
987
1576
  if (!isEligibleFirstOpen(context, now(), recentInstallMaxAgeMs)) return 'ineligible';
988
1577
 
989
1578
  const installAttemptId = state.firstOpen.installAttemptId
990
1579
  ?? makeInstallAttemptId(options.randomUUID);
1580
+ const startingDeterministicEpoch = deterministicEpoch;
1581
+ const startingLifecycleGeneration = lifecycleGeneration;
991
1582
  state = {
992
1583
  ...state,
993
1584
  status: 'resolving',
@@ -997,8 +1588,35 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
997
1588
  attemptedAt: now(),
998
1589
  },
999
1590
  };
1000
- persistAndNotify();
1591
+ const firstOpenReservationPersisted = persistAndNotify();
1592
+ // Persistence notifies host state hooks synchronously. They may reset/dispose this client or
1593
+ // capture a deterministic destination. In all three cases the reserved id no longer belongs to
1594
+ // the current lifecycle and must never cross the network boundary.
1595
+ if (disposed) return 'ineligible';
1596
+ if (lifecycleGeneration !== startingLifecycleGeneration
1597
+ || deterministicEpoch !== startingDeterministicEpoch
1598
+ || state.pending
1599
+ || state.firstOpen.completed) return 'deterministic_pending';
1600
+ if (options.storage && !firstOpenReservationPersisted) {
1601
+ // An unpersisted id must never leave the process: after a crash the retry would mint a new
1602
+ // id and the server could count/route the same install twice. Keep the exact nonce in memory,
1603
+ // arm a persistence retry without incrementing matcher attempts, and send nothing until a
1604
+ // synchronous durable write succeeds. Memory-only clients have no restart contract and may
1605
+ // continue normally.
1606
+ state = {
1607
+ ...state,
1608
+ status: 'retryable_error',
1609
+ firstOpen: {
1610
+ ...state.firstOpen,
1611
+ nextRetryAt: now() + retryBaseMs,
1612
+ },
1613
+ };
1614
+ reportError(new Error('Pulse Links: first-open install attempt id was not durably persisted'));
1615
+ notifyState();
1616
+ return 'retry_scheduled';
1617
+ }
1001
1618
 
1619
+ const accountEmail = normalizeAccountEmail(context.accountEmail);
1002
1620
  const body = {
1003
1621
  appBundleId: context.appBundleId.trim(),
1004
1622
  platform: 'ios' as const,
@@ -1006,6 +1624,10 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1006
1624
  firstOpen: true,
1007
1625
  installAttemptId,
1008
1626
  ...normalizeAnonymousFirstOpenSignals(context),
1627
+ // Sent only when the app already knows it. The resolver treats an address that matches
1628
+ // exactly one recent message as proof of origin; anything else falls back to the
1629
+ // probabilistic path, so a shared or unknown address costs nothing.
1630
+ ...(accountEmail ? { accountEmail } : {}),
1009
1631
  };
1010
1632
  try {
1011
1633
  const response = await withTimeout(
@@ -1018,16 +1640,27 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1018
1640
  },
1019
1641
  requestTimeoutMs,
1020
1642
  );
1021
- if (state.pending) return 'deterministic_pending';
1022
- if (state.firstOpen.completed) return 'deterministic_pending';
1643
+ if (disposed) return 'ineligible';
1644
+ if (deterministicEpoch !== startingDeterministicEpoch
1645
+ || state.pending
1646
+ || state.firstOpen.completed) return 'deterministic_pending';
1023
1647
  if (response.status === 204) {
1024
- patchFirstOpen({ completed: true, attempts: 0, nextRetryAt: 0 });
1025
- patchState({ status: 'idle' });
1648
+ completeFirstOpen('idle', {
1649
+ status: 'NOT_FOUND',
1650
+ rail: 'no_route',
1651
+ routed: false,
1652
+ reason: firstOpenNoRouteReason(response),
1653
+ });
1026
1654
  return 'no_match';
1027
1655
  }
1028
- if (response.status === 400 || response.status === 404 || response.status === 410) {
1029
- patchFirstOpen({ completed: true, nextRetryAt: 0 });
1030
- patchState({ status: 'terminal_error' });
1656
+ if (response.status === 400 || response.status === 404
1657
+ || response.status === 410 || response.status === 413) {
1658
+ completeFirstOpen('terminal_error', {
1659
+ status: 'FAILURE',
1660
+ rail: 'no_route',
1661
+ routed: false,
1662
+ reason: 'invalid_request',
1663
+ });
1031
1664
  return 'terminal_error';
1032
1665
  }
1033
1666
  if (!response.ok) {
@@ -1035,41 +1668,118 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1035
1668
  scheduleFirstOpenRetry();
1036
1669
  return 'retry_scheduled';
1037
1670
  }
1038
- patchFirstOpen({ completed: true, nextRetryAt: 0 });
1039
- patchState({ status: 'terminal_error' });
1671
+ completeFirstOpen('terminal_error', {
1672
+ status: 'FAILURE',
1673
+ rail: 'no_route',
1674
+ routed: false,
1675
+ reason: 'invalid_response',
1676
+ });
1677
+ return 'terminal_error';
1678
+ }
1679
+ let rawLink: unknown;
1680
+ try {
1681
+ rawLink = await response.json();
1682
+ } catch {
1683
+ completeFirstOpen('terminal_error', {
1684
+ status: 'FAILURE',
1685
+ rail: 'no_route',
1686
+ routed: false,
1687
+ reason: 'invalid_response',
1688
+ });
1040
1689
  return 'terminal_error';
1041
1690
  }
1691
+ if (disposed) return 'ineligible';
1692
+ if (deterministicEpoch !== startingDeterministicEpoch
1693
+ || state.pending
1694
+ || state.firstOpen.completed) return 'deterministic_pending';
1042
1695
  const link = normalizeResolved<Action>(
1043
- await response.json(),
1696
+ rawLink,
1044
1697
  installAttemptId.replace(/-/g, ''),
1045
1698
  'unique_probabilistic',
1046
1699
  allowedActions,
1047
1700
  now(),
1048
1701
  );
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' });
1702
+ if (!link) {
1703
+ completeFirstOpen('terminal_error', {
1704
+ status: 'FAILURE',
1705
+ rail: 'no_route',
1706
+ routed: false,
1707
+ reason: 'invalid_response',
1708
+ });
1055
1709
  return 'terminal_error';
1056
1710
  }
1711
+ // A probabilistic result is navigation intent, never a remote URL transport. Only the two
1712
+ // harmless closed discovery actions are accepted; all billing/account/entitlement actions
1713
+ // and even an app-owned deepLink are rejected before application code runs.
1714
+ if (link.matchBasis !== 'unique_probabilistic'
1715
+ || link.matchGuaranteed
1716
+ || link.deepLink !== undefined
1717
+ || !link.action
1718
+ || !PROBABILISTIC_ACTIONS.has(String(link.action))
1719
+ || sensitiveActions.has(link.action)
1720
+ || !options.onAction) {
1721
+ completeFirstOpen('terminal_error', {
1722
+ status: 'FAILURE',
1723
+ rail: 'no_route',
1724
+ routed: false,
1725
+ reason: 'policy_rejected',
1726
+ });
1727
+ return 'terminal_error';
1728
+ }
1729
+ if (deterministicEpoch !== startingDeterministicEpoch
1730
+ || state.pending
1731
+ || state.firstOpen.completed) return 'deterministic_pending';
1057
1732
  if (await applyResolved(link, null)) {
1058
- patchFirstOpen({ completed: true, attempts: 0, nextRetryAt: 0 });
1733
+ // `applyResolved()` returns true only after the host callback crossed its irreversible
1734
+ // commit boundary successfully. A deterministic capture can arrive while that callback
1735
+ // awaits; it remains pending for the next lifecycle, but it must not erase the terminal
1736
+ // truth that this probabilistic journey really routed.
1737
+ completeFirstOpen(state.pending ? 'pending' : 'applied', {
1738
+ status: 'FOUND',
1739
+ rail: 'fast_route',
1740
+ routed: true,
1741
+ reason: 'matched',
1742
+ matchBasis: link.matchBasis,
1743
+ confidence: link.confidence,
1744
+ ...(link.campaignId !== undefined ? { campaignId: link.campaignId } : {}),
1745
+ ...(link.experimentId !== undefined ? { experimentId: link.experimentId } : {}),
1746
+ ...(link.variantId !== undefined ? { variantId: link.variantId } : {}),
1747
+ });
1059
1748
  return 'matched';
1060
1749
  }
1750
+ if (deterministicEpoch !== startingDeterministicEpoch
1751
+ || state.pending
1752
+ || state.firstOpen.completed) return 'deterministic_pending';
1061
1753
  if (state.firstOpen.nextRetryAt === 0) scheduleFirstOpenRetry();
1062
1754
  return 'retry_scheduled';
1063
1755
  } catch (error) {
1756
+ if (disposed) return 'ineligible';
1064
1757
  reportError(error);
1758
+ if (deterministicEpoch !== startingDeterministicEpoch
1759
+ || state.pending
1760
+ || state.firstOpen.completed) return 'deterministic_pending';
1065
1761
  scheduleFirstOpenRetry();
1066
1762
  return 'retry_scheduled';
1067
1763
  }
1068
1764
  };
1069
1765
 
1766
+ const matchFirstOpen = (
1767
+ context: AnonymousFirstOpenContext,
1768
+ ): Promise<AnonymousFirstOpenResult> => {
1769
+ if (firstOpenMatching) return firstOpenMatching;
1770
+ let owned: Promise<AnonymousFirstOpenResult>;
1771
+ owned = matchFirstOpenOnce(context).finally(() => {
1772
+ if (firstOpenMatching === owned) firstOpenMatching = null;
1773
+ });
1774
+ firstOpenMatching = owned;
1775
+ return owned;
1776
+ };
1777
+
1070
1778
  const captureAndroidInstallReferrer = async (
1071
1779
  bridge: AndroidInstallReferrerBridge,
1072
1780
  ): Promise<AndroidInstallReferrerResult> => {
1781
+ const referrerLifecycleGeneration = lifecycleGeneration;
1782
+ const referrerDeterministicEpoch = deterministicEpoch;
1073
1783
  let raw: unknown;
1074
1784
  try {
1075
1785
  raw = await bridge.getDeferredHandoff();
@@ -1078,6 +1788,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1078
1788
  return { status: 'SERVICE_UNAVAILABLE', retryable: true, attempts: 0 };
1079
1789
  }
1080
1790
  const result = normalizeAndroidInstallReferrerResult(raw);
1791
+ if (disposed
1792
+ || lifecycleGeneration !== referrerLifecycleGeneration
1793
+ || deterministicEpoch !== referrerDeterministicEpoch) return result;
1081
1794
  if (result.status === 'OK' && result.token) {
1082
1795
  capture(result.token, 'android_install_referrer');
1083
1796
  }
@@ -1095,6 +1808,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1095
1808
  lastFirstOpenContext ? state.firstOpen.nextRetryAt : 0,
1096
1809
  accountRetryAt,
1097
1810
  state.outcomeQueue[0]?.nextRetryAt ?? 0,
1811
+ state.terminalDelivery?.nextRetryAt ?? 0,
1098
1812
  ].filter((value) => value > now());
1099
1813
  if (candidates.length === 0) return;
1100
1814
  const next = Math.min(...candidates);
@@ -1102,6 +1816,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1102
1816
  retryTimer = null;
1103
1817
  void process();
1104
1818
  void flushResolverOutcomes();
1819
+ void flushTerminalDelivery();
1105
1820
  if (lastFirstOpenContext && state.firstOpen.nextRetryAt <= now()) {
1106
1821
  void matchFirstOpen(lastFirstOpenContext);
1107
1822
  }
@@ -1120,6 +1835,7 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1120
1835
  scheduleWake();
1121
1836
  void process();
1122
1837
  void flushResolverOutcomes();
1838
+ void flushTerminalDelivery();
1123
1839
 
1124
1840
  return {
1125
1841
  capture,
@@ -1138,6 +1854,9 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1138
1854
  dispose: () => {
1139
1855
  if (disposed) return;
1140
1856
  disposed = true;
1857
+ lifecycleGeneration += 1;
1858
+ deterministicEpoch += 1;
1859
+ terminalDeliveryGeneration += 1;
1141
1860
  if (retryTimer) clearTimeout(retryTimer);
1142
1861
  retryTimer = null;
1143
1862
  unsubscribeAccount?.();
@@ -1147,6 +1866,11 @@ export function createPulseLinkClient<Action extends string = DeferredLinkAction
1147
1866
  reset: () => {
1148
1867
  accountRetryAt = 0;
1149
1868
  accountAttempts = 0;
1869
+ lifecycleGeneration += 1;
1870
+ deterministicEpoch += 1;
1871
+ terminalDeliveryGeneration += 1;
1872
+ terminalDeliverySending = null;
1873
+ deterministicTokenDeferredAfterCommittedJourney = null;
1150
1874
  state = emptyState();
1151
1875
  persistAndNotify();
1152
1876
  },
@@ -1199,11 +1923,11 @@ function stableOutcomeKey(linkId: string, name: DeferredLinkOutcomeName): string
1199
1923
  return `${first.toString(16).padStart(8, '0')}${second.toString(16).padStart(8, '0')}|${name}`;
1200
1924
  }
1201
1925
 
1202
- function boundedMetadata(value: unknown): string | null | undefined {
1926
+ function boundedMetadata(value: unknown, maxLength = 256): string | null | undefined {
1203
1927
  if (value === null) return null;
1204
1928
  if (typeof value !== 'string' && typeof value !== 'number') return undefined;
1205
1929
  const result = String(value).trim();
1206
- return result.length > 0 && result.length <= 256 ? result : undefined;
1930
+ return result.length > 0 && result.length <= maxLength ? result : undefined;
1207
1931
  }
1208
1932
 
1209
1933
  function normalizeResolved<Action extends string>(
@@ -1246,9 +1970,9 @@ function normalizeResolved<Action extends string>(
1246
1970
  if (!Number.isFinite(expiry) || expiry <= now) return null;
1247
1971
  }
1248
1972
  const source = boundedMetadata(value.source);
1249
- const campaignId = boundedMetadata(value.campaignId);
1250
- const experimentId = boundedMetadata(value.experimentId);
1251
- const variantId = boundedMetadata(value.variantId);
1973
+ const campaignId = boundedMetadata(value.campaignId, 128);
1974
+ const experimentId = boundedMetadata(value.experimentId, 128);
1975
+ const variantId = boundedMetadata(value.variantId, 128);
1252
1976
  return {
1253
1977
  id,
1254
1978
  ...(action ? { action } : {}),
@@ -1286,6 +2010,15 @@ function normalizeLocale(locale: string): string {
1286
2010
  return locale.trim().replace(/_/g, '-');
1287
2011
  }
1288
2012
 
2013
+ function firstOpenNoRouteReason(response: Response): AnonymousFirstOpenTerminalReason {
2014
+ let raw = '';
2015
+ try { raw = response.headers?.get('X-Encore-Match-Outcome')?.trim().toLowerCase() ?? ''; }
2016
+ catch { raw = ''; }
2017
+ return NO_ROUTE_REASONS.has(raw as AnonymousFirstOpenTerminalReason)
2018
+ ? raw as AnonymousFirstOpenTerminalReason
2019
+ : 'unmatched';
2020
+ }
2021
+
1289
2022
  function normalizeAnonymousFirstOpenSignals(
1290
2023
  context: AnonymousFirstOpenContext,
1291
2024
  ): Partial<Omit<AnonymousFirstOpenContext, 'appBundleId' | 'locale' | 'platform' | 'installedAt'>> {
@@ -1382,6 +2115,20 @@ function resolverOutcomeRetryDelay(
1382
2115
  return Math.max(1, Math.floor(ceiling * jitter));
1383
2116
  }
1384
2117
 
2118
+ /**
2119
+ * An address is only useful to the resolver if it is the same shape the send was recorded with.
2120
+ * Anything that is not plausibly an address is dropped rather than sent: a malformed value can
2121
+ * only ever fail to match, and not sending it keeps the payload free of stray user input.
2122
+ */
2123
+ export function normalizeAccountEmail(raw: string | undefined | null): string | null {
2124
+ const value = (raw ?? '').trim().toLowerCase();
2125
+ if (value.length < 3 || value.length > 255) return null;
2126
+ const at = value.indexOf('@');
2127
+ if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1) return null;
2128
+ if (/\s/.test(value)) return null;
2129
+ return value;
2130
+ }
2131
+
1385
2132
  async function withTimeout(
1386
2133
  fetcher: typeof fetch,
1387
2134
  input: string,
@@ -1397,6 +2144,55 @@ async function withTimeout(
1397
2144
  }
1398
2145
  }
1399
2146
 
2147
+ async function withPromiseTimeout<T>(
2148
+ factory: () => Promise<T>,
2149
+ timeoutMs: number,
2150
+ operation: 'account claim' | 'account pending',
2151
+ ): Promise<T> {
2152
+ let timer: ReturnType<typeof setTimeout> | null = null;
2153
+ try {
2154
+ return await Promise.race([
2155
+ Promise.resolve().then(factory),
2156
+ new Promise<T>((_resolve, reject) => {
2157
+ timer = setTimeout(
2158
+ () => reject(new Error(`Pulse Links: ${operation} timed out`)),
2159
+ timeoutMs,
2160
+ );
2161
+ }),
2162
+ ]);
2163
+ } finally {
2164
+ if (timer) clearTimeout(timer);
2165
+ }
2166
+ }
2167
+
2168
+ async function withTerminalDeliveryTimeout(
2169
+ acknowledgement: Promise<AnonymousFirstOpenTerminalDisposition | boolean>,
2170
+ timeoutMs: number,
2171
+ ): Promise<AnonymousFirstOpenTerminalDisposition | boolean> {
2172
+ let timer: ReturnType<typeof setTimeout> | null = null;
2173
+ try {
2174
+ return await Promise.race([
2175
+ acknowledgement,
2176
+ new Promise<AnonymousFirstOpenTerminalDisposition | boolean>((_resolve, reject) => {
2177
+ timer = setTimeout(
2178
+ () => reject(new Error('Pulse Links: terminal delivery acknowledgement timed out')),
2179
+ timeoutMs,
2180
+ );
2181
+ }),
2182
+ ]);
2183
+ } finally {
2184
+ if (timer) clearTimeout(timer);
2185
+ }
2186
+ }
2187
+
2188
+ function normalizeTerminalDeliveryDisposition(
2189
+ value: AnonymousFirstOpenTerminalDisposition | boolean,
2190
+ ): AnonymousFirstOpenTerminalDisposition {
2191
+ if (value === true || value === 'accepted') return 'accepted';
2192
+ if (value === 'drop') return 'drop';
2193
+ return 'retry';
2194
+ }
2195
+
1400
2196
  async function postResolverOutcome(
1401
2197
  fetcher: typeof fetch,
1402
2198
  resolverBaseUrl: string,
@@ -1488,6 +2284,117 @@ function normalizeOccurredAt(value: unknown): string | null {
1488
2284
  return new Date(timestamp).toISOString() === value ? value : null;
1489
2285
  }
1490
2286
 
2287
+ function readTerminalDelivery(raw: unknown): PersistedFirstOpenTerminalDelivery | null {
2288
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
2289
+ const value = raw as Record<string, unknown>;
2290
+ const eventId = typeof value.eventId === 'string' && UUID_V4.test(value.eventId)
2291
+ ? value.eventId.toLowerCase()
2292
+ : null;
2293
+ const status = value.status === 'FOUND' || value.status === 'NOT_FOUND' || value.status === 'FAILURE'
2294
+ ? value.status
2295
+ : null;
2296
+ const rail = value.rail === 'fast_route' || value.rail === 'no_route' ? value.rail : null;
2297
+ const reason = typeof value.reason === 'string'
2298
+ ? value.reason as AnonymousFirstOpenTerminalReason
2299
+ : null;
2300
+ const occurredAt = normalizeOccurredAt(value.occurredAt);
2301
+ const attempts = typeof value.attempts === 'number'
2302
+ && Number.isSafeInteger(value.attempts)
2303
+ && value.attempts >= 0
2304
+ && value.attempts <= 100_000
2305
+ ? value.attempts
2306
+ : null;
2307
+ const nextRetryAt = typeof value.nextRetryAt === 'number'
2308
+ && Number.isSafeInteger(value.nextRetryAt)
2309
+ && value.nextRetryAt >= 0
2310
+ && value.nextRetryAt <= 8_640_000_000_000_000
2311
+ ? value.nextRetryAt
2312
+ : null;
2313
+ if (value.disposition === 'drop') {
2314
+ const allowedKeys = new Set(['eventId', 'disposition', 'attempts', 'nextRetryAt']);
2315
+ if (!eventId
2316
+ || attempts === null
2317
+ || nextRetryAt === null
2318
+ || Object.keys(value).some((key) => !allowedKeys.has(key))) return null;
2319
+ return { eventId, disposition: 'drop', attempts, nextRetryAt };
2320
+ }
2321
+ const matchBasis = typeof value.matchBasis === 'string'
2322
+ && MATCH_BASES.has(value.matchBasis as DeferredLinkMatchBasis)
2323
+ ? value.matchBasis as DeferredLinkMatchBasis
2324
+ : null;
2325
+ const confidence = typeof value.confidence === 'number'
2326
+ && Number.isFinite(value.confidence)
2327
+ && value.confidence >= 0
2328
+ && value.confidence <= 1
2329
+ ? value.confidence
2330
+ : null;
2331
+ const metadata: Partial<Pick<
2332
+ AnonymousFirstOpenTerminalOutcome,
2333
+ 'campaignId' | 'experimentId' | 'variantId'
2334
+ >> = {};
2335
+ let metadataValid = true;
2336
+ for (const key of ['campaignId', 'experimentId', 'variantId'] as const) {
2337
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
2338
+ const field = value[key];
2339
+ if (field === null) metadata[key] = null;
2340
+ else if (typeof field === 'string'
2341
+ && field.length > 0
2342
+ && field.length <= 128
2343
+ && field.trim() === field) metadata[key] = field;
2344
+ else metadataValid = false;
2345
+ }
2346
+ const hasFoundDimensions = Object.prototype.hasOwnProperty.call(value, 'matchBasis')
2347
+ && Object.prototype.hasOwnProperty.call(value, 'confidence');
2348
+ const hasAnyAttributionDimension = Object.prototype.hasOwnProperty.call(value, 'matchBasis')
2349
+ || Object.prototype.hasOwnProperty.call(value, 'confidence')
2350
+ || Object.prototype.hasOwnProperty.call(value, 'campaignId')
2351
+ || Object.prototype.hasOwnProperty.call(value, 'experimentId')
2352
+ || Object.prototype.hasOwnProperty.call(value, 'variantId');
2353
+ const validSemanticOutcome = status === 'FOUND'
2354
+ ? rail === 'fast_route'
2355
+ && value.routed === true
2356
+ && reason === 'matched'
2357
+ && hasFoundDimensions
2358
+ && matchBasis === 'unique_probabilistic'
2359
+ && confidence !== null
2360
+ && metadataValid
2361
+ : status === 'NOT_FOUND'
2362
+ ? rail === 'no_route' && value.routed === false
2363
+ && reason !== null && NO_ROUTE_REASONS.has(reason)
2364
+ && !hasAnyAttributionDimension
2365
+ : status === 'FAILURE'
2366
+ ? rail === 'no_route' && value.routed === false
2367
+ && reason !== null && FAILURE_REASONS.has(reason)
2368
+ && !hasAnyAttributionDimension
2369
+ : false;
2370
+ if (!eventId
2371
+ || !status
2372
+ || !rail
2373
+ || !reason
2374
+ || !occurredAt
2375
+ || attempts === null
2376
+ || nextRetryAt === null
2377
+ || Object.prototype.hasOwnProperty.call(value, 'disposition')
2378
+ || value.retryable !== false
2379
+ || !validSemanticOutcome) return null;
2380
+ return {
2381
+ eventId,
2382
+ status,
2383
+ rail,
2384
+ routed: value.routed as boolean,
2385
+ retryable: false,
2386
+ reason,
2387
+ occurredAt,
2388
+ ...(status === 'FOUND' ? {
2389
+ matchBasis: matchBasis as DeferredLinkMatchBasis,
2390
+ confidence: confidence as number,
2391
+ ...metadata,
2392
+ } : {}),
2393
+ attempts,
2394
+ nextRetryAt,
2395
+ };
2396
+ }
2397
+
1491
2398
  function readState(storage: ConfigStorage | undefined, key: string): PersistedDeferredLinkState {
1492
2399
  if (!storage) return emptyState();
1493
2400
  try {
@@ -1535,6 +2442,7 @@ function readState(storage: ConfigStorage | undefined, key: string): PersistedDe
1535
2442
  .slice(-MAX_NOTIFIED_OUTCOMES)
1536
2443
  : [];
1537
2444
  const outcomeQueue = readResolverOutcomeQueue(parsed.outcomeQueue);
2445
+ const terminalDelivery = readTerminalDelivery(parsed.terminalDelivery);
1538
2446
  return {
1539
2447
  version: 1,
1540
2448
  status: status === 'resolving' ? (pending ? 'pending' : 'idle') : status,
@@ -1546,6 +2454,7 @@ function readState(storage: ConfigStorage | undefined, key: string): PersistedDe
1546
2454
  ...outcomeQueue.map((outcome) => outcome.transitionKey),
1547
2455
  ])].slice(-MAX_NOTIFIED_OUTCOMES),
1548
2456
  outcomeQueue,
2457
+ terminalDelivery,
1549
2458
  firstOpen: {
1550
2459
  installAttemptId: attemptId,
1551
2460
  completed: firstValue.completed === true,
@@ -1563,9 +2472,32 @@ function writeState(
1563
2472
  storage: ConfigStorage | undefined,
1564
2473
  key: string,
1565
2474
  state: PersistedDeferredLinkState,
1566
- ): void {
1567
- if (!storage) return;
1568
- try { void storage.set(key, JSON.stringify(state)); } catch { /* in-memory operation continues */ }
2475
+ allowKnownAsyncBestEffort = true,
2476
+ ): boolean {
2477
+ if (!storage) return true;
2478
+ const knownAsync = storage.supportsDurableSyncWrites === false
2479
+ || ASYNCHRONOUS_STORAGE_ADAPTERS.has(storage as object);
2480
+ if (knownAsync && !allowKnownAsyncBestEffort) return false;
2481
+ try {
2482
+ const result = (storage.set as unknown as (storageKey: string, value: string) => unknown)(
2483
+ key,
2484
+ JSON.stringify(state),
2485
+ );
2486
+ if (isThenable(result)) {
2487
+ ASYNCHRONOUS_STORAGE_ADAPTERS.add(storage as object);
2488
+ void Promise.resolve(result).catch(() => undefined);
2489
+ return false;
2490
+ }
2491
+ return !knownAsync;
2492
+ } catch {
2493
+ return false;
2494
+ }
2495
+ }
2496
+
2497
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
2498
+ return (typeof value === 'object' && value !== null) || typeof value === 'function'
2499
+ ? typeof (value as { then?: unknown }).then === 'function'
2500
+ : false;
1569
2501
  }
1570
2502
 
1571
2503
  function safeTimestamp(value: unknown): number {