@wireai/activation 0.9.0 → 0.9.2

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.
@@ -20,17 +20,42 @@ import { k as ReviewTarget, g as ReviewDecisionResponse, j as ReviewSubmission }
20
20
  * a missing `fetch`, or a network failure is swallowed and the call returns immediately.
21
21
  */
22
22
  declare const submitReview: (target: ReviewTarget | undefined, review: ReviewSubmission) => void;
23
- /** Options for the best-effort review decision fetch. */
23
+ /**
24
+ * Options for the best-effort review decision fetch.
25
+ *
26
+ * ── WHAT THE DEPLOYED SERVER ACTUALLY READS (verified 2026-07-17) ────────────────────────
27
+ *
28
+ * `GET /v1/reviews/decision` declares exactly two query params — `session_id` and
29
+ * `device_key` — plus the `Authorization` header. That is the whole wire. Verified against
30
+ * the deployed OpenAPI schema, not against intent.
31
+ *
32
+ * This type is CLOSED on purpose. Hosts that hand-rolled this fetch invented `user_id` and
33
+ * `session_count` query params believing "the server ignores what it doesn't read, so passing
34
+ * it is always safe". Both are no-ops: the route declares neither. `session_count` is real, but
35
+ * only on the questionnaire POST body — which is precisely how it copy-pasted its way into a
36
+ * reviews call site and sat there doing nothing. They cost a wire lie — a call site that reads as though
37
+ * identity and a session counter reach the firing brain when neither does. So they are not
38
+ * offered here. Pass identity as `deviceKey`; the session count the server reasons about is the
39
+ * one IT derives from the event stream keyed by `deviceKey`, not one the client asserts.
40
+ *
41
+ * The client-side session floor is a LOCAL rule, not a wire param: use `ReviewConfig.minSessions`
42
+ * (evaluated by `useReviewGate` against the kit's own `wire_review_<id>_sessions` counter).
43
+ */
24
44
  interface FetchReviewDecisionOptions {
25
45
  /**
26
46
  * The onboarding session id, when there IS one. OPTIONAL on purpose: the review gate lives on
27
47
  * the home feed, where a user legitimately has no onboarding session. `deviceKey` is the real
28
- * identity for this call. (Today's prod still requires `session_id` and answers 422 without it;
29
- * the server relaxation is unmerged. A 422 is a non-2xx, so it returns null like any other
30
- * failure, and the local rules stand. Permissive wire: do NOT make this required here.)
48
+ * identity for this call. (The server relaxed `session_id` to optional on 2026-07-16 and the
49
+ * relaxation IS deployed the route no longer 422s without it. Permissive wire: do NOT make
50
+ * this required here.)
31
51
  */
32
52
  sessionId?: string;
33
- /** A stable, non-PII device id. The decision endpoint reads it for cooldown + min-sessions. */
53
+ /**
54
+ * A stable, non-PII device id — THE identity for this call. The decision endpoint reads it for
55
+ * cooldown + min-sessions, and it is the key the server groups a device's events under. A host
56
+ * whose own identity is a user id passes that id here rather than reaching for a `user_id`
57
+ * param the route does not declare.
58
+ */
34
59
  deviceKey?: string;
35
60
  }
36
61
  /**
@@ -57,12 +82,29 @@ interface FetchReviewDecisionOptions {
57
82
  * • else → null, and ONLY then. Null means "the server genuinely has no opinion", which is
58
83
  * the one case where falling back to local rules is correct.
59
84
  *
60
- * Never throws: unreachable, non-2xx (incl. today's 422), bad JSON, or a missing `fetch` all
61
- * resolve to null. The kit does not call this internally; a host awaits it and passes the
62
- * result straight to `useReviewGate({ decision })`.
85
+ * Never throws: unreachable, non-2xx, bad JSON, or a missing `fetch` all resolve to null. The
86
+ * kit does not call this internally; a host awaits it and passes the result straight to
87
+ * `useReviewGate({ decision })`.
88
+ *
89
+ * ── DON'T RACE THIS AGAINST A LOCAL TIMER ────────────────────────────────────────────────
90
+ *
91
+ * The return type is `ReviewDecisionResponse`, which carries `arm` alongside `{fire, reason}`.
92
+ * Hand the WHOLE object to the gate and echo `arm` into the submission's `meta.firing_arm`;
93
+ * narrowing it to `{fire, reason}` on the way through silently kills per-arm attribution
94
+ * across a reweighting of the experiment.
95
+ *
96
+ * A host that starts its own dwell timer in parallel with this fetch has built a race a slow
97
+ * server loses: the timer fires, the local rules show the prompt, and the `{fire:false}` still
98
+ * in flight arrives too late to stop it. Do not hand-roll that. `useReviewGate` already owns
99
+ * the wait — set `ReviewConfig.timeoutFallbackMs` and the local rules stay parked until either
100
+ * the decision lands or the window expires, whichever comes first.
63
101
  *
64
102
  * const decision = await fetchReviewDecision(target, { deviceKey });
65
- * const gate = useReviewGate({ config, decision: decision ?? undefined, storage });
103
+ * const gate = useReviewGate({
104
+ * config: { id: "home", minSessions: 2, timeoutFallbackMs: 3000 },
105
+ * decision: decision ?? undefined, // pass it whole — keep `arm`
106
+ * storage,
107
+ * });
66
108
  */
67
109
  declare const fetchReviewDecision: (target: ReviewTarget | undefined, options?: FetchReviewDecisionOptions) => Promise<ReviewDecisionResponse | null>;
68
110
  /** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
@@ -20,17 +20,42 @@ import { k as ReviewTarget, g as ReviewDecisionResponse, j as ReviewSubmission }
20
20
  * a missing `fetch`, or a network failure is swallowed and the call returns immediately.
21
21
  */
22
22
  declare const submitReview: (target: ReviewTarget | undefined, review: ReviewSubmission) => void;
23
- /** Options for the best-effort review decision fetch. */
23
+ /**
24
+ * Options for the best-effort review decision fetch.
25
+ *
26
+ * ── WHAT THE DEPLOYED SERVER ACTUALLY READS (verified 2026-07-17) ────────────────────────
27
+ *
28
+ * `GET /v1/reviews/decision` declares exactly two query params — `session_id` and
29
+ * `device_key` — plus the `Authorization` header. That is the whole wire. Verified against
30
+ * the deployed OpenAPI schema, not against intent.
31
+ *
32
+ * This type is CLOSED on purpose. Hosts that hand-rolled this fetch invented `user_id` and
33
+ * `session_count` query params believing "the server ignores what it doesn't read, so passing
34
+ * it is always safe". Both are no-ops: the route declares neither. `session_count` is real, but
35
+ * only on the questionnaire POST body — which is precisely how it copy-pasted its way into a
36
+ * reviews call site and sat there doing nothing. They cost a wire lie — a call site that reads as though
37
+ * identity and a session counter reach the firing brain when neither does. So they are not
38
+ * offered here. Pass identity as `deviceKey`; the session count the server reasons about is the
39
+ * one IT derives from the event stream keyed by `deviceKey`, not one the client asserts.
40
+ *
41
+ * The client-side session floor is a LOCAL rule, not a wire param: use `ReviewConfig.minSessions`
42
+ * (evaluated by `useReviewGate` against the kit's own `wire_review_<id>_sessions` counter).
43
+ */
24
44
  interface FetchReviewDecisionOptions {
25
45
  /**
26
46
  * The onboarding session id, when there IS one. OPTIONAL on purpose: the review gate lives on
27
47
  * the home feed, where a user legitimately has no onboarding session. `deviceKey` is the real
28
- * identity for this call. (Today's prod still requires `session_id` and answers 422 without it;
29
- * the server relaxation is unmerged. A 422 is a non-2xx, so it returns null like any other
30
- * failure, and the local rules stand. Permissive wire: do NOT make this required here.)
48
+ * identity for this call. (The server relaxed `session_id` to optional on 2026-07-16 and the
49
+ * relaxation IS deployed the route no longer 422s without it. Permissive wire: do NOT make
50
+ * this required here.)
31
51
  */
32
52
  sessionId?: string;
33
- /** A stable, non-PII device id. The decision endpoint reads it for cooldown + min-sessions. */
53
+ /**
54
+ * A stable, non-PII device id — THE identity for this call. The decision endpoint reads it for
55
+ * cooldown + min-sessions, and it is the key the server groups a device's events under. A host
56
+ * whose own identity is a user id passes that id here rather than reaching for a `user_id`
57
+ * param the route does not declare.
58
+ */
34
59
  deviceKey?: string;
35
60
  }
36
61
  /**
@@ -57,12 +82,29 @@ interface FetchReviewDecisionOptions {
57
82
  * • else → null, and ONLY then. Null means "the server genuinely has no opinion", which is
58
83
  * the one case where falling back to local rules is correct.
59
84
  *
60
- * Never throws: unreachable, non-2xx (incl. today's 422), bad JSON, or a missing `fetch` all
61
- * resolve to null. The kit does not call this internally; a host awaits it and passes the
62
- * result straight to `useReviewGate({ decision })`.
85
+ * Never throws: unreachable, non-2xx, bad JSON, or a missing `fetch` all resolve to null. The
86
+ * kit does not call this internally; a host awaits it and passes the result straight to
87
+ * `useReviewGate({ decision })`.
88
+ *
89
+ * ── DON'T RACE THIS AGAINST A LOCAL TIMER ────────────────────────────────────────────────
90
+ *
91
+ * The return type is `ReviewDecisionResponse`, which carries `arm` alongside `{fire, reason}`.
92
+ * Hand the WHOLE object to the gate and echo `arm` into the submission's `meta.firing_arm`;
93
+ * narrowing it to `{fire, reason}` on the way through silently kills per-arm attribution
94
+ * across a reweighting of the experiment.
95
+ *
96
+ * A host that starts its own dwell timer in parallel with this fetch has built a race a slow
97
+ * server loses: the timer fires, the local rules show the prompt, and the `{fire:false}` still
98
+ * in flight arrives too late to stop it. Do not hand-roll that. `useReviewGate` already owns
99
+ * the wait — set `ReviewConfig.timeoutFallbackMs` and the local rules stay parked until either
100
+ * the decision lands or the window expires, whichever comes first.
63
101
  *
64
102
  * const decision = await fetchReviewDecision(target, { deviceKey });
65
- * const gate = useReviewGate({ config, decision: decision ?? undefined, storage });
103
+ * const gate = useReviewGate({
104
+ * config: { id: "home", minSessions: 2, timeoutFallbackMs: 3000 },
105
+ * decision: decision ?? undefined, // pass it whole — keep `arm`
106
+ * storage,
107
+ * });
66
108
  */
67
109
  declare const fetchReviewDecision: (target: ReviewTarget | undefined, options?: FetchReviewDecisionOptions) => Promise<ReviewDecisionResponse | null>;
68
110
  /** Options for a reported app event. `deviceKey` groups a device's sessions server-side. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "private": false,
5
5
  "description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
6
6
  "author": "Malik Chohra <malik@getwireai.com>",
@@ -27,6 +27,7 @@ import {
27
27
  clearPersistedSession,
28
28
  loadPersistedSession,
29
29
  sessionStorageKey,
30
+ shouldClearOnComplete,
30
31
  DEFAULT_SESSION_TTL_MS,
31
32
  type LoadedSession,
32
33
  } from "./session/persistedSession";
@@ -52,6 +53,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
52
53
  storage,
53
54
  sessionTtlMs = DEFAULT_SESSION_TTL_MS,
54
55
  persistKey,
56
+ retainSessionOnComplete,
55
57
  userContext,
56
58
  userId,
57
59
  }) => {
@@ -123,15 +125,24 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
123
125
  };
124
126
  }, [storage, storageKey, sessionTtlMs, session]);
125
127
 
126
- // Clear the cached id the moment the flow completes, so the next onboarding on this
127
- // device starts a fresh session. Dropped/degraded paths deliberately do NOT clear —
128
+ // By DEFAULT, clear the cached id the moment the flow completes, so the next onboarding on
129
+ // this device starts a fresh session. Dropped/degraded paths deliberately do NOT clear —
128
130
  // a dropped session must stay resumable within the TTL.
131
+ //
132
+ // OPT-IN (`retainSessionOnComplete`): a multi-stage / replay-after-complete signup re-enters
133
+ // (often via a `key=` remount) WITHIN one signup. If we cleared, that re-entry would find no
134
+ // seed and mint a fresh `metadata.sessionId` → a phantom second `session_started` on the same
135
+ // funnel. When the host opts in, we LEAVE the seed in place so the re-entry resumes the SAME
136
+ // session (one funnel start); freshness for a genuinely new run then rides the TTL (past
137
+ // `sessionTtlMs` → fresh mint) and an explicit new-run signal (a changed `persistKey`).
138
+ // `shouldClearOnComplete` is the pure, unit-tested seam; the `storage` guard is unchanged, so a
139
+ // host that does not opt in lands on the byte-for-byte legacy clear-on-complete path.
129
140
  const handleComplete = useCallback(
130
141
  (result: OnboardingResult) => {
131
- if (storage) clearPersistedSession(storage, storageKey);
142
+ if (storage && shouldClearOnComplete(retainSessionOnComplete)) clearPersistedSession(storage, storageKey);
132
143
  onComplete(result);
133
144
  },
134
- [storage, storageKey, onComplete],
145
+ [storage, storageKey, retainSessionOnComplete, onComplete],
135
146
  );
136
147
 
137
148
  const sessionId = session?.id ?? "";
@@ -224,10 +224,25 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
224
224
  }
225
225
  })();
226
226
 
227
- // The queue's OWN awaited POST. Reads `res.ok` to drive retry/dequeue. NEVER throws — a missing
228
- // fetch, a rejecting network, or a JSON error resolves to `false` (batch stays, retry schedules).
229
- const postBatch = async (events: ClientEvent[]): Promise<boolean> => {
230
- if (!target?.serverUrl || events.length === 0) return false;
227
+ // The outcome of one POST attempt:
228
+ // "ok" → the server accepted the batch (2xx) dequeue it.
229
+ // "drop" → a BATCH-SPECIFIC permanent error (this batch will never be accepted no matter how
230
+ // often we retry) discard it and continue, so it can't block the good events behind
231
+ // it (head-of-line). See DROP_STATUSES.
232
+ // "retry" → transient or tenant-wide (network/5xx/429/auth) → keep the batch and back off.
233
+ type PostResult = "ok" | "drop" | "retry";
234
+
235
+ // Statuses where retrying THIS batch is futile because the batch itself is the problem — a
236
+ // malformed body (400/422), a too-large body (413), or a wrong route (404). Dropping a poison
237
+ // batch is what stops it from stalling the whole backlog. Deliberately NOT here: 401/403 (auth is
238
+ // tenant-wide, not batch-specific — dropping would silently lose EVERY event on a recoverable
239
+ // credential blip, so we keep retrying/pausing instead) and 429/5xx (transient).
240
+ const DROP_STATUSES = new Set([400, 404, 413, 422]);
241
+
242
+ // The queue's OWN awaited POST. Classifies the response to drive dequeue/drop/retry. NEVER throws —
243
+ // a missing fetch, a rejecting network, or an abort resolves to "retry" (batch stays, retry schedules).
244
+ const postBatch = async (events: ClientEvent[]): Promise<PostResult> => {
245
+ if (!target?.serverUrl || events.length === 0) return "retry";
231
246
  const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
232
247
  const timer = setTimeout(() => controller?.abort(), 15_000);
233
248
  try {
@@ -240,9 +255,12 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
240
255
  body: JSON.stringify({ events }),
241
256
  signal: controller?.signal,
242
257
  });
243
- return !!(res && (res as { ok?: boolean }).ok);
258
+ if (res && (res as { ok?: boolean }).ok) return "ok";
259
+ const status = (res as { status?: number } | undefined)?.status;
260
+ if (typeof status === "number" && DROP_STATUSES.has(status)) return "drop";
261
+ return "retry";
244
262
  } catch {
245
- return false;
263
+ return "retry";
246
264
  } finally {
247
265
  clearTimeout(timer);
248
266
  }
@@ -280,12 +298,13 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
280
298
  try {
281
299
  while (pending.length > 0) {
282
300
  const batch = pending.slice(0, batchSize);
283
- const ok = await postBatch(batch.map((item) => item.event));
284
- if (!ok) {
301
+ const result = await postBatch(batch.map((item) => item.event));
302
+ if (result === "retry") {
285
303
  scheduleRetry();
286
304
  return;
287
305
  }
288
- // Dequeue exactly the acked batch by id (pending may have grown while in flight).
306
+ // "ok" (accepted) or "drop" (poison batch discarded so it can't block the rest): remove
307
+ // exactly this batch by id (pending may have grown while in flight) and keep draining.
289
308
  const acked = new Set(batch.map((item) => item.id));
290
309
  pending = pending.filter((item) => !acked.has(item.id));
291
310
  persist();
@@ -75,7 +75,15 @@ export const fetchQuestionnaireDecision = async (
75
75
  const res = await fetch(url, { headers });
76
76
  if (!res || !res.ok) return null;
77
77
  const json = (await res.json()) as QuestionnaireDecisionResponse | null;
78
- return json ?? null;
78
+ // A body without a boolean `fire` is not a decision — guard it exactly as the mirror
79
+ // `fetchReviewDecision` does. Without this, a malformed 2xx body flows straight through:
80
+ // `{}` reads as a truthy verdict whose `fire` is undefined (silently "never fire", killing
81
+ // a questionnaire the local rules would have shown), and a stringy `{fire:"yes"}` reads as
82
+ // FIRE while carrying no `questionnaire`, so the host renders a gate with an undefined
83
+ // definition. Only a genuine boolean-`fire` body is a decision; everything else → null (the
84
+ // server has no opinion → the local rules stand).
85
+ if (!json || typeof json.fire !== "boolean") return null;
86
+ return json;
79
87
  } catch {
80
88
  /* unreachable / non-2xx / bad JSON / missing-fetch - never show */
81
89
  return null;
@@ -39,17 +39,42 @@ export const submitReview = (
39
39
  }
40
40
  };
41
41
 
42
- /** Options for the best-effort review decision fetch. */
42
+ /**
43
+ * Options for the best-effort review decision fetch.
44
+ *
45
+ * ── WHAT THE DEPLOYED SERVER ACTUALLY READS (verified 2026-07-17) ────────────────────────
46
+ *
47
+ * `GET /v1/reviews/decision` declares exactly two query params — `session_id` and
48
+ * `device_key` — plus the `Authorization` header. That is the whole wire. Verified against
49
+ * the deployed OpenAPI schema, not against intent.
50
+ *
51
+ * This type is CLOSED on purpose. Hosts that hand-rolled this fetch invented `user_id` and
52
+ * `session_count` query params believing "the server ignores what it doesn't read, so passing
53
+ * it is always safe". Both are no-ops: the route declares neither. `session_count` is real, but
54
+ * only on the questionnaire POST body — which is precisely how it copy-pasted its way into a
55
+ * reviews call site and sat there doing nothing. They cost a wire lie — a call site that reads as though
56
+ * identity and a session counter reach the firing brain when neither does. So they are not
57
+ * offered here. Pass identity as `deviceKey`; the session count the server reasons about is the
58
+ * one IT derives from the event stream keyed by `deviceKey`, not one the client asserts.
59
+ *
60
+ * The client-side session floor is a LOCAL rule, not a wire param: use `ReviewConfig.minSessions`
61
+ * (evaluated by `useReviewGate` against the kit's own `wire_review_<id>_sessions` counter).
62
+ */
43
63
  export interface FetchReviewDecisionOptions {
44
64
  /**
45
65
  * The onboarding session id, when there IS one. OPTIONAL on purpose: the review gate lives on
46
66
  * the home feed, where a user legitimately has no onboarding session. `deviceKey` is the real
47
- * identity for this call. (Today's prod still requires `session_id` and answers 422 without it;
48
- * the server relaxation is unmerged. A 422 is a non-2xx, so it returns null like any other
49
- * failure, and the local rules stand. Permissive wire: do NOT make this required here.)
67
+ * identity for this call. (The server relaxed `session_id` to optional on 2026-07-16 and the
68
+ * relaxation IS deployed the route no longer 422s without it. Permissive wire: do NOT make
69
+ * this required here.)
50
70
  */
51
71
  sessionId?: string;
52
- /** A stable, non-PII device id. The decision endpoint reads it for cooldown + min-sessions. */
72
+ /**
73
+ * A stable, non-PII device id — THE identity for this call. The decision endpoint reads it for
74
+ * cooldown + min-sessions, and it is the key the server groups a device's events under. A host
75
+ * whose own identity is a user id passes that id here rather than reaching for a `user_id`
76
+ * param the route does not declare.
77
+ */
53
78
  deviceKey?: string;
54
79
  }
55
80
 
@@ -77,12 +102,29 @@ export interface FetchReviewDecisionOptions {
77
102
  * • else → null, and ONLY then. Null means "the server genuinely has no opinion", which is
78
103
  * the one case where falling back to local rules is correct.
79
104
  *
80
- * Never throws: unreachable, non-2xx (incl. today's 422), bad JSON, or a missing `fetch` all
81
- * resolve to null. The kit does not call this internally; a host awaits it and passes the
82
- * result straight to `useReviewGate({ decision })`.
105
+ * Never throws: unreachable, non-2xx, bad JSON, or a missing `fetch` all resolve to null. The
106
+ * kit does not call this internally; a host awaits it and passes the result straight to
107
+ * `useReviewGate({ decision })`.
108
+ *
109
+ * ── DON'T RACE THIS AGAINST A LOCAL TIMER ────────────────────────────────────────────────
110
+ *
111
+ * The return type is `ReviewDecisionResponse`, which carries `arm` alongside `{fire, reason}`.
112
+ * Hand the WHOLE object to the gate and echo `arm` into the submission's `meta.firing_arm`;
113
+ * narrowing it to `{fire, reason}` on the way through silently kills per-arm attribution
114
+ * across a reweighting of the experiment.
115
+ *
116
+ * A host that starts its own dwell timer in parallel with this fetch has built a race a slow
117
+ * server loses: the timer fires, the local rules show the prompt, and the `{fire:false}` still
118
+ * in flight arrives too late to stop it. Do not hand-roll that. `useReviewGate` already owns
119
+ * the wait — set `ReviewConfig.timeoutFallbackMs` and the local rules stay parked until either
120
+ * the decision lands or the window expires, whichever comes first.
83
121
  *
84
122
  * const decision = await fetchReviewDecision(target, { deviceKey });
85
- * const gate = useReviewGate({ config, decision: decision ?? undefined, storage });
123
+ * const gate = useReviewGate({
124
+ * config: { id: "home", minSessions: 2, timeoutFallbackMs: 3000 },
125
+ * decision: decision ?? undefined, // pass it whole — keep `arm`
126
+ * storage,
127
+ * });
86
128
  */
87
129
  export const fetchReviewDecision = async (
88
130
  target: ReviewTarget | undefined,
@@ -141,3 +141,20 @@ export const clearPersistedSession = (storage: WireOnboardingStorage, key: strin
141
141
  // Best-effort.
142
142
  }
143
143
  };
144
+
145
+ /**
146
+ * The opt-in decision for `WireOnboarding.handleComplete`: given the host's
147
+ * `retainSessionOnComplete` opt-in, whether a completion should CLEAR the persisted seed.
148
+ * (The caller still gates on `storage` being present — this only encodes the opt-in axis, so the
149
+ * legacy `if (storage) clearPersistedSession(...)` guard stays byte-for-byte for a host that does
150
+ * not opt in.)
151
+ *
152
+ * - undefined / false (the default) → `true`: clear on completion, so the NEXT onboarding on this
153
+ * device mints a fresh session. This is the legacy single-stage behavior (e.g. Myelino).
154
+ * - true → `false`: LEAVE the seed in place so a same-signup re-entry/remount within the TTL
155
+ * resumes the SAME session (one funnel start) instead of minting a phantom second `started`.
156
+ * Freshness for a genuinely new run is then governed by the TTL (past `sessionTtlMs` → fresh
157
+ * mint) and an explicit new-run signal (a changed `persistKey`).
158
+ */
159
+ export const shouldClearOnComplete = (retainSessionOnComplete: boolean | undefined): boolean =>
160
+ !retainSessionOnComplete;
package/src/types.ts CHANGED
@@ -211,6 +211,22 @@ export type WireOnboardingProps = {
211
211
  * accounts mid-flow. Only meaningful with `storage`.
212
212
  */
213
213
  persistKey?: string;
214
+ /**
215
+ * OPT-IN: keep the persisted session seed alive ACROSS completion, so a multi-stage or
216
+ * replay-after-complete re-entry WITHIN one signup (typically a `key=` remount) resumes the
217
+ * SAME session instead of minting a fresh `metadata.sessionId` — which the backend would adopt
218
+ * as a second `contextId`, double-counting a `session_started` on the funnel.
219
+ *
220
+ * Default (omitted / `false`): a completion CLEARS the seed, so the next onboarding on this
221
+ * device starts fresh — the legacy single-stage behavior. Leave it unset and NOTHING changes.
222
+ *
223
+ * When `true`: the seed survives completion, and freshness for a genuinely new run is governed
224
+ * by the TTL (past `sessionTtlMs` → a fresh seed is minted) and an explicit new-run signal
225
+ * (scope a new `persistKey`, e.g. a per-signup id, to force a fresh seed within the TTL).
226
+ *
227
+ * Only meaningful with `storage`. Dropped/degraded sessions are unaffected (they never clear).
228
+ */
229
+ retainSessionOnComplete?: boolean;
214
230
  };
215
231
 
216
232
  /** Backend-supplied progress, read off `response.props.progress` when present. */