@wireai/activation 0.13.6 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AGENTS.md +21 -9
  2. package/CHANGELOG.md +172 -1
  3. package/INTEGRATION_PROMPT.md +7 -4
  4. package/README.md +10 -2
  5. package/dist/analytics/index.d.mts +9 -2
  6. package/dist/analytics/index.d.ts +9 -2
  7. package/dist/analytics/index.js +56 -12
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +56 -12
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/{currentSession-DngW-QoD.d.mts → currentSession-CUvTOchb.d.mts} +35 -6
  12. package/dist/{currentSession-C5976akx.d.ts → currentSession-CW_5Mq4O.d.ts} +35 -6
  13. package/dist/index.d.mts +24 -6
  14. package/dist/index.d.ts +24 -6
  15. package/dist/index.js +631 -548
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +631 -548
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.js.map +1 -1
  20. package/dist/questionnaire/index.mjs.map +1 -1
  21. package/dist/reviews/index.js.map +1 -1
  22. package/dist/reviews/index.mjs.map +1 -1
  23. package/llms.txt +2 -2
  24. package/package.json +1 -1
  25. package/src/OnboardingFlow.tsx +4 -3
  26. package/src/WireOnboarding.tsx +4 -3
  27. package/src/activation/useWireActivation.ts +14 -1
  28. package/src/activation/wireActivation.ts +68 -2
  29. package/src/analytics/analyticsFacade.ts +24 -0
  30. package/src/analytics/eventQueue.ts +106 -11
  31. package/src/analytics/reportClientEvent.ts +31 -7
  32. package/src/analytics/useAnalytics.ts +17 -0
  33. package/src/context/deviceId.ts +10 -3
  34. package/src/permissions/permissionMemory.ts +12 -1
  35. package/src/session/persistedSession.ts +32 -8
  36. package/src/session-analytics/lifecycle.ts +26 -5
  37. package/src/session-analytics/reportSessionStart.ts +14 -10
  38. package/src/session-analytics/useLifecycleEvents.ts +70 -32
  39. package/src/session-analytics/useSessionStart.ts +57 -15
  40. package/src/types.ts +16 -6
  41. package/src/utils/readPlan.ts +8 -5
@@ -52,16 +52,29 @@ export type LoadedSession = {
52
52
  };
53
53
 
54
54
  /**
55
- * Race a storage read against a ceiling, resolving `undefined` when the adapter did not answer.
55
+ * The verdict of a read that RAN OUT OF TIME. A distinct value, and deliberately never `undefined`:
56
+ * an `undefined` here is byte-identical to "the adapter answered, there is nothing stored", and that
57
+ * conflation is what let a timed-out read mint a fresh session id and WRITE IT OVER the still-valid
58
+ * one it had not managed to read yet. The event queue reached the same conclusion independently
59
+ * (`analytics/eventQueue.ts`); this is the same sentinel for the same reason.
60
+ *
61
+ * "Did not answer in time" is not "there is nothing there", and the difference is the difference
62
+ * between starting clean and destroying a session mid-onboarding.
63
+ */
64
+ export const READ_TIMED_OUT: unique symbol = Symbol("wireai:storage-read-timeout");
65
+
66
+ /**
67
+ * Race a storage read against a ceiling, resolving {@link READ_TIMED_OUT} when the adapter did not
68
+ * answer in time.
56
69
  * Exported so the permission memory reads through the same ceiling as the session seed rather than
57
70
  * carrying a second copy of it. NOT the kit's only such helper: `features/cache.ts`,
58
71
  * `session-analytics/lifecycle.ts` and `analytics/eventQueue.ts` each keep their own local timeout
59
72
  * for their own transports. Consolidating those is a separate change, not this one.
60
73
  */
61
- export const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
74
+ export const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | typeof READ_TIMED_OUT> => {
62
75
  let timer: ReturnType<typeof setTimeout>;
63
- const timeout = new Promise<undefined>((resolve) => {
64
- timer = setTimeout(() => resolve(undefined), ms);
76
+ const timeout = new Promise<typeof READ_TIMED_OUT>((resolve) => {
77
+ timer = setTimeout(() => resolve(READ_TIMED_OUT), ms);
65
78
  });
66
79
  // Clear the timer once the race settles, so a won read doesn't leave a ≤ms no-op
67
80
  // timer holding the closure alive.
@@ -96,12 +109,19 @@ export const loadPersistedSession = async (
96
109
  key: string,
97
110
  ttlMs: number = DEFAULT_SESSION_TTL_MS,
98
111
  ): Promise<LoadedSession> => {
99
- let stored: PersistedSession | undefined;
112
+ let raw: string | null | typeof READ_TIMED_OUT;
100
113
  try {
101
- stored = parsePersisted(await withTimeout(storage.getItem(key), READ_TIMEOUT_MS));
114
+ raw = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
102
115
  } catch {
103
- stored = undefined;
116
+ raw = null;
104
117
  }
118
+ // TIMED OUT: the mount cannot wait, so it still gets an id and starts clean — but it must NOT
119
+ // persist it. Writing here overwrites a session the read was still fetching, and a valid
120
+ // mid-onboarding session id is exactly the thing whose loss the server reads as a phantom drop.
121
+ // Losing persistence for ONE launch is recoverable; overwriting the blob is not.
122
+ if (raw === READ_TIMED_OUT) return { id: makeSessionId(), resumed: false };
123
+
124
+ const stored = parsePersisted(raw);
105
125
  if (stored && Date.now() - stored.ts < ttlMs) {
106
126
  return { id: stored.id, resumed: true };
107
127
  }
@@ -121,7 +141,11 @@ export const peekPersistedSession = async (
121
141
  key: string,
122
142
  ): Promise<{ id: string; ts: number } | undefined> => {
123
143
  try {
124
- return parsePersisted(await withTimeout(storage.getItem(key), READ_TIMEOUT_MS));
144
+ const raw = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
145
+ // A timed-out read is `undefined` here exactly as before: this function only READS, so
146
+ // "unknown" and "absent" lead to the same caller behaviour and nothing destructive follows.
147
+ if (raw === READ_TIMED_OUT) return undefined;
148
+ return parsePersisted(raw);
125
149
  } catch {
126
150
  return undefined;
127
151
  }
@@ -51,10 +51,18 @@ export const firstOpenStorageKey = (appId: string): string => `wireai:first_open
51
51
  /** Ceiling on the flag read — a hung adapter degrades to the in-memory latch, never a stuck gate. */
52
52
  const READ_TIMEOUT_MS = 1_500;
53
53
 
54
- const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
54
+ /**
55
+ * The verdict of a flag read that ran out of time. DISTINCT from `null`/`undefined`, because on this
56
+ * particular read those mean "this install has never fired its first open" — and acting on that
57
+ * belief is an irreversible emit. Same sentinel, same reasoning, as `session/persistedSession.ts`
58
+ * and `analytics/eventQueue.ts`.
59
+ */
60
+ const READ_TIMED_OUT: unique symbol = Symbol("wireai:first-open-read-timeout");
61
+
62
+ const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | typeof READ_TIMED_OUT> => {
55
63
  let timer: ReturnType<typeof setTimeout>;
56
- const timeout = new Promise<undefined>((resolve) => {
57
- timer = setTimeout(() => resolve(undefined), ms);
64
+ const timeout = new Promise<typeof READ_TIMED_OUT>((resolve) => {
65
+ timer = setTimeout(() => resolve(READ_TIMED_OUT), ms);
58
66
  });
59
67
  return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
60
68
  };
@@ -185,6 +193,12 @@ export const reportFirstOpen = (opts: ReportFirstOpenOptions): void => {
185
193
  void (async () => {
186
194
  try {
187
195
  const seen = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
196
+ // TIMED OUT: the flag may well say "already fired" — the read is still in flight, it just blew
197
+ // the ceiling. Emitting on that guess turns once-EVER into once-per-slow-launch, and the
198
+ // top-of-funnel count inflates on exactly the devices (cold start, slow storage) that are
199
+ // slowest. Declining costs at most one launch's first_open on a genuinely first install, and
200
+ // the next launch retries with a read that lands. See the sentinel above.
201
+ if (seen === READ_TIMED_OUT) return;
188
202
  // A prior launch already fired + wrote the flag → once-ever satisfied, no-op.
189
203
  if (seen) return;
190
204
  emitFirstOpen(opts);
@@ -195,8 +209,15 @@ export const reportFirstOpen = (opts: ReportFirstOpenOptions): void => {
195
209
  // Missing/broken storage write — swallow.
196
210
  }
197
211
  } catch {
198
- // Unreadable flag the in-memory latch still caps us at one fire this process.
199
- emitFirstOpen(opts);
212
+ // UNREADABLE (a locked / full / permission-denied adapter): the same question with a worse
213
+ // answer, because it fails on every launch. This used to emit, which meant `app.first_open`
214
+ // fired on EVERY launch of that install for as long as the adapter stayed broken — the top of
215
+ // the funnel counting one device as an unbounded number of installs. The kit's standing rule
216
+ // for an unverifiable persistence answer (0.13.0 `autoJoinKey`, 0.14.0 lifecycle device keys)
217
+ // is to decline rather than to emit something that corrupts the metric. Same verdict here.
218
+ //
219
+ // A host that wants a first_open on a device with no working storage should pass no `storage`
220
+ // at all: that is the documented degraded mode and it still fires once per process.
200
221
  }
201
222
  })();
202
223
  };
@@ -33,6 +33,7 @@
33
33
  */
34
34
  import { setCurrentSessionId } from "../analytics/currentSession";
35
35
  import {
36
+ buildEventsRequest,
36
37
  makeSessionId,
37
38
  warnOnSkippedEvents,
38
39
  type ClientEvent,
@@ -158,16 +159,19 @@ export const reportSessionStart = (opts: ReportSessionStartOptions): void => {
158
159
  return;
159
160
  }
160
161
 
161
- // Fallback: this emitter's own direct POST when no sink is wired.
162
- if (!target?.serverUrl) return;
163
- const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
164
- const headers: Record<string, string> = { "Content-Type": "application/json" };
165
- if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
166
- void fetch(url, {
167
- method: "POST",
168
- headers,
169
- body: JSON.stringify({ events: [event] }),
170
- })
162
+ // Fallback: this emitter's own direct POST when no sink is wired — routed through the ONE
163
+ // canonical `/v1/events` builder (url + headers + body + the epoch-ms → ISO8601 `ts`
164
+ // conversion), exactly as `lifecycle.ts` already does with its own fallback.
165
+ //
166
+ // It used to hand-build the request here. That was harmless as long as this event carried no
167
+ // field needing wire conversion, but it made this the ONE send path of five that skipped the
168
+ // converter — on the event `min_sessions` is counted from, where the failure shape is a 200 with
169
+ // `{written: 0, skipped: N}` and no client-visible error at all (the 0.13.0 `ts` defect). The
170
+ // choke-point comment in `reportClientEvent.ts` claimed five paths and delivered four;
171
+ // `sendPathChokePoint.test.ts` now holds the claim to the code.
172
+ const req = buildEventsRequest(target, [event as unknown as ClientEvent]);
173
+ if (!req) return;
174
+ void fetch(req.url, req.init)
171
175
  .then((res) => {
172
176
  // A 200 can still carry `skipped:N` — the server took the request and threw the event away.
173
177
  // Log-only; this path has nothing to retry either way.
@@ -11,9 +11,10 @@
11
11
  * Firing moments mirror {@link useSessionStart} exactly:
12
12
  * • ON MOUNT — the app opened (cold start / provider first render). `app.first_open` fires here
13
13
  * too (once ever, gated by the persisted flag in `reportFirstOpen`). When the kit's AUTO device
14
- * key is the one in play (no host `deviceKey`, `config.storage` present) the mount fire waits on
15
- * one storage read so both events carry the PERSISTED key, not a freshly minted one — see the
16
- * `hydrateAutoDeviceKey` note below.
14
+ * key is the one in play (no host `deviceKey`, `config.storage` present) the fire waits on one
15
+ * storage read so both events carry the PERSISTED key, not a freshly minted one — and if that
16
+ * read settles NON-DURABLE the key is refused outright rather than degraded to the per-launch
17
+ * mint. See the `hydrateDeviceIdentity` note below.
17
18
  * • ON FOREGROUND after a real background of at least {@link BACKGROUND_SESSION_MS} (30 min) — a
18
19
  * new app-open, so a fresh `app.session_started` fires. A quick app-switch does NOT count.
19
20
  *
@@ -32,7 +33,7 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
32
33
 
33
34
  import { createEventQueue, type EnvelopeSource, type EventQueue } from "../analytics/eventQueue";
34
35
  import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
35
- import { hydrateAutoDeviceKey, resolveAutoDeviceKey } from "../context/deviceId";
36
+ import { hydrateDeviceIdentity } from "../context/deviceId";
36
37
  import { resolveIdentity } from "../identity/identityRecord";
37
38
  import { collectDeviceContext } from "../device/deviceContext";
38
39
  import type { WireOnboardingStorage } from "../session/persistedSession";
@@ -50,8 +51,10 @@ export interface LifecycleConfig {
50
51
  appVersion?: string;
51
52
  /** Tenant/app id — namespaces the first-open flag AND the hook's internal queue storage key. */
52
53
  appId?: string;
53
- /** Host storage (AsyncStorage subset). Enables the persisted once-ever first-open flag AND the
54
- * offline durability of the hook's internal queue. Omit it and both degrade to in-memory. */
54
+ /** Host storage (AsyncStorage subset). Enables the persisted once-ever first-open flag, the
55
+ * offline durability of the hook's internal queue, AND the auto `device_key` fallback the last
56
+ * one only when the adapter actually persists (one that throws or rejects gets no fallback, the
57
+ * same verdict as no storage at all). Omit it and all three degrade to in-memory. */
55
58
  storage?: WireOnboardingStorage;
56
59
  }
57
60
 
@@ -98,6 +101,10 @@ export const useLifecycleEvents = (
98
101
  const queueRef = useRef<EventQueue | undefined>(undefined);
99
102
 
100
103
  useEffect(() => {
104
+ // Set by the cleanup below: an in-flight storage read must not fire an app-open for a mount
105
+ // that is already gone. Declared first because every async fire path closes over it.
106
+ let cancelled = false;
107
+
101
108
  const resolveSink = (): ((event: ClientEvent) => void) | undefined => {
102
109
  const { config: cfg, options: opts } = latest.current;
103
110
  // Host-owned shared queue wins — one queue across the whole kit.
@@ -130,13 +137,17 @@ export const useLifecycleEvents = (
130
137
  * one. Two disjoint identity spaces again: the counter the rule reads could never increase, so
131
138
  * `min_sessions` was structurally unsatisfiable and the gate never fired from the server side.
132
139
  *
133
- * ONLY WITH `storage`: the auto id is per-INSTALL only when it can be persisted. With no
134
- * storage it would be per-LAUNCH, which would make every open look like a brand-new device and
135
- * corrupt `min_sessions` in the other direction. So no storage no fallback, same as before.
140
+ * ONLY WITH `storage` THAT ACTUALLY WORKED: the auto id is per-INSTALL only when it can be
141
+ * persisted. With no storage or with an adapter that threw / rejected it is per-LAUNCH,
142
+ * which makes every open look like a brand-new device and corrupts `min_sessions` in the other
143
+ * direction (while inflating distinct-device counts on top). So no storage → no fallback, and a
144
+ * non-durable read → no fallback either: `openAutoDeviceKey` below hands this function
145
+ * `undefined` in both cases, and it never reaches for the synchronous mint itself.
136
146
  */
137
147
  const resolveDeviceKey = (
138
148
  cfg: LifecycleConfig | undefined,
139
149
  opts: UseLifecycleEventsOptions,
150
+ autoDeviceKey: string | undefined,
140
151
  ): string | undefined => {
141
152
  // A HOST-supplied key is recorded on the process provenance registry, so a `<WireOnboarding>`
142
153
  // mount that was not given one can tell "this app owns no device id" from "this app owns one
@@ -148,8 +159,33 @@ export const useLifecycleEvents = (
148
159
  scope: cfg?.appId,
149
160
  })?.value;
150
161
  if (host) return host;
151
- if (!cfg?.storage) return undefined;
152
- return resolveAutoDeviceKey({ appId: cfg.appId, storage: cfg.storage });
162
+ return autoDeviceKey;
163
+ };
164
+
165
+ /**
166
+ * Resolve the auto `device_key` for ONE app-open and then fire it, so EVERY fire path (mount and
167
+ * foreground re-fire alike) goes through the same vetting.
168
+ *
169
+ * A host-supplied key or a config with no `storage` fires SYNCHRONOUSLY — there is nothing to
170
+ * read. Otherwise the fire waits on `hydrateDeviceIdentity`, the provenance-carrying read, and
171
+ * honours the rule `context/deviceId.ts` states for exactly these callers: *"Callers that write a
172
+ * key onto the wire as a cross-launch join must read `durable` and refuse a `false`."* A refusal
173
+ * emits the event with NO auto key — the same verdict `<WireOnboarding>` reaches on a broken
174
+ * adapter, and strictly better than a key that differs on every launch. Never rejects: the read
175
+ * resolves to a record or `undefined`, never a throw.
176
+ */
177
+ const openAutoDeviceKey = (fire: (autoDeviceKey: string | undefined) => void): void => {
178
+ const { config: cfg, options: opts } = latest.current;
179
+ const hostKey =
180
+ typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
181
+ if (hostKey || !cfg?.storage) {
182
+ fire(undefined);
183
+ return;
184
+ }
185
+ void hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }).then((identity) => {
186
+ if (cancelled) return;
187
+ fire(identity?.durable ? identity.value : undefined);
188
+ });
153
189
  };
154
190
 
155
191
  // ONE per-open session id for the MOUNT open, shared by first_open AND session_started below.
@@ -164,7 +200,7 @@ export const useLifecycleEvents = (
164
200
  // session_start — the EXISTING emitter routed through the sink (offline-buffered, one guard).
165
201
  // Accepts the per-open id so the mount open reuses `mountOpenSessionId`; omit it and the emitter
166
202
  // mints a fresh id (a genuinely new open). Either way the once-guard dedupes within the open.
167
- const fireSession = (sessionId?: string) => {
203
+ const fireSession = (sessionId: string | undefined, autoDeviceKey: string | undefined) => {
168
204
  const { config: cfg, options: opts } = latest.current;
169
205
  if (opts.enabled === false) return;
170
206
  if (!cfg?.serverUrl && !opts.sink) return;
@@ -176,7 +212,7 @@ export const useLifecycleEvents = (
176
212
  sink: resolveSink(),
177
213
  sessionId,
178
214
  userId: opts.userId,
179
- deviceKey: resolveDeviceKey(cfg, opts),
215
+ deviceKey: resolveDeviceKey(cfg, opts, autoDeviceKey),
180
216
  sessionCount: opts.sessionCount,
181
217
  appVersion: cfg?.appVersion ?? device.appVersion,
182
218
  platform: Platform.OS,
@@ -189,8 +225,8 @@ export const useLifecycleEvents = (
189
225
  // `session_started` for `mountOpenSessionId` before first_open references the same id; then
190
226
  // first_open — once ever (persisted flag + in-memory latch inside reportFirstOpen), pinned to the
191
227
  // SAME per-open id so it is never a phantom session.
192
- const fireMountOpen = () => {
193
- fireSession(mountOpenSessionId);
228
+ const fireMountOpen = (autoDeviceKey: string | undefined) => {
229
+ fireSession(mountOpenSessionId, autoDeviceKey);
194
230
 
195
231
  const { config: cfg, options: opts } = latest.current;
196
232
  if (opts.enabled === false) return;
@@ -204,7 +240,7 @@ export const useLifecycleEvents = (
204
240
  storage: cfg?.storage,
205
241
  appId: cfg?.appId,
206
242
  userId: opts.userId,
207
- deviceKey: resolveDeviceKey(cfg, opts),
243
+ deviceKey: resolveDeviceKey(cfg, opts, autoDeviceKey),
208
244
  sessionCount: opts.sessionCount,
209
245
  appVersion: cfg?.appVersion ?? device.appVersion,
210
246
  platform: Platform.OS,
@@ -223,21 +259,12 @@ export const useLifecycleEvents = (
223
259
  // 1 — and `first_open`, fired once ever, ended up under a key no later event shares, which breaks
224
260
  // `first_open` → `activated` cohorting too. One storage read at mount buys both back.
225
261
  //
226
- // A host-supplied `deviceKey` needs no read, and with no `storage` there is nothing to read (and
227
- // `resolveDeviceKey` deliberately does not fall back), so both keep firing synchronously at mount.
228
- let cancelled = false;
229
- const { config: mountCfg, options: mountOpts } = latest.current;
230
- const hostKey =
231
- typeof mountOpts.deviceKey === "string" && mountOpts.deviceKey.trim()
232
- ? mountOpts.deviceKey
233
- : undefined;
234
- if (!hostKey && mountCfg?.storage) {
235
- void hydrateAutoDeviceKey({ appId: mountCfg.appId, storage: mountCfg.storage }).then(() => {
236
- if (!cancelled) fireMountOpen();
237
- });
238
- } else {
239
- fireMountOpen();
240
- }
262
+ // 0.14.0: awaiting the read was only half of it. The read can settle NON-DURABLE (a locked /
263
+ // full / permission-denied adapter it throws or rejects and the id stays process-scoped), and
264
+ // the `.then` fired regardless, so a degraded store walked the per-launch key straight back onto
265
+ // the counted events: two launches over a throwing MMKV produced four distinct `wdev_*`. The
266
+ // outcome is now read through `openAutoDeviceKey`, which refuses a `durable: false` id.
267
+ openAutoDeviceKey(fireMountOpen);
241
268
 
242
269
  // Foreground after a real background = a new app-open.
243
270
  let backgroundedAt: number | null = null;
@@ -249,7 +276,11 @@ export const useLifecycleEvents = (
249
276
  if (state === "active") {
250
277
  const since = backgroundedAt;
251
278
  backgroundedAt = null;
252
- if (since != null && Date.now() - since >= BACKGROUND_SESSION_MS) fireSession();
279
+ // A new open re-runs the same vetting: the auto key must still be a durable one, and a
280
+ // degraded read that released its latches gets a fresh attempt rather than a cached verdict.
281
+ if (since != null && Date.now() - since >= BACKGROUND_SESSION_MS) {
282
+ openAutoDeviceKey((autoDeviceKey) => fireSession(undefined, autoDeviceKey));
283
+ }
253
284
  }
254
285
  };
255
286
  const sub = AppState.addEventListener("change", onChange);
@@ -258,6 +289,13 @@ export const useLifecycleEvents = (
258
289
  cancelled = true;
259
290
  // RN >= 0.65 returns a subscription with remove(); guard for older shims.
260
291
  if (sub && typeof (sub as { remove?: () => void }).remove === "function") sub.remove();
292
+ // Tear the hook's OWN queue down. This hook re-creates it on every remount against a fixed
293
+ // explicit storage key, so without a teardown the dead queue keeps a live backoff timer over
294
+ // the same slot the replacement now owns: it wakes up, drains, empties, and `removeItem`s the
295
+ // replacement's persisted `app.session_started`. A host-supplied `sink` is NOT touched — that
296
+ // queue belongs to the host and outlives this mount by design.
297
+ queueRef.current?.dispose();
298
+ queueRef.current = undefined;
261
299
  };
262
300
  // Mount-only effect: firing reads fresh values via `latest`, so no reactive deps.
263
301
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -2,7 +2,10 @@
2
2
  * useSessionStart — the optional convenience hook that fires {@link reportSessionStart} for you.
3
3
  *
4
4
  * Two firing moments, mirroring the reference apps' session-counter semantics:
5
- * • ON MOUNT — the app opened (cold start or the provider first rendered).
5
+ * • ON MOUNT — the app opened (cold start or the provider first rendered). When the kit's AUTO
6
+ * device key is the one in play (no host `deviceKey`, `config.storage` present) the fire waits
7
+ * on one storage read, so the event carries the PERSISTED key rather than a fresh per-launch
8
+ * mint, and it refuses the key outright if that read settles non-durable (0.14.0).
6
9
  * • ON FOREGROUND after a real background — when AppState returns to `active` having been
7
10
  * backgrounded for at least {@link BACKGROUND_SESSION_MS} (30 min), that's a NEW open, so a
8
11
  * fresh session fires. A quick app-switch (under the threshold) does NOT count as a new open.
@@ -26,7 +29,7 @@ import { useEffect, useRef } from "react";
26
29
  import { AppState, Platform, type AppStateStatus } from "react-native";
27
30
 
28
31
  import type { ClientEventTarget } from "../analytics/reportClientEvent";
29
- import { resolveAutoDeviceKey } from "../context/deviceId";
32
+ import { hydrateDeviceIdentity } from "../context/deviceId";
30
33
  import { collectDeviceContext } from "../device/deviceContext";
31
34
  import type { WireOnboardingStorage } from "../session/persistedSession";
32
35
  import { reportSessionStart } from "./reportSessionStart";
@@ -44,8 +47,9 @@ export interface SessionStartConfig {
44
47
  appVersion?: string;
45
48
  /** Tenant/app id — namespaces the auto `device_key` fallback below. */
46
49
  appId?: string;
47
- /** Host storage (AsyncStorage subset). Present → `app.session_started` falls back to the kit's
48
- * persisted auto `device_key` when the host passes none. Absent no fallback (see below). */
50
+ /** Host storage (AsyncStorage subset). Present AND actually persisting → `app.session_started`
51
+ * falls back to the kit's persisted auto `device_key` when the host passes none. Absent, or an
52
+ * adapter that throws / rejects → no fallback (a per-launch key is worse than none, see below). */
49
53
  storage?: WireOnboardingStorage;
50
54
  }
51
55
 
@@ -78,8 +82,13 @@ export const useSessionStart = (
78
82
  latest.current = { config, options };
79
83
 
80
84
  useEffect(() => {
85
+ // Set by the cleanup below: an in-flight storage read must not fire an app-open for a mount that
86
+ // is already gone. Declared first because the async fire path closes over it.
87
+ let cancelled = false;
88
+
81
89
  /**
82
- * The `device_key` this open rides under. A host-supplied id always wins.
90
+ * The `device_key` this open rides under. A host-supplied id always wins; the auto id arrives
91
+ * already vetted from `openAutoDeviceKey` below (a non-durable one never gets here).
83
92
  *
84
93
  * BACKPORTED FROM `useLifecycleEvents` (which had this and this hook did not): `min_sessions` is
85
94
  * computed server-side by counting distinct `app.session_started` grouped by
@@ -88,21 +97,50 @@ export const useSessionStart = (
88
97
  * spaces, and a counter that could never increase. The two session-start paths must not diverge
89
98
  * on the thing the firing rule reads.
90
99
  *
91
- * ONLY WITH `storage`, for the same reason as the sibling hook: without persistence the auto id
92
- * is per-LAUNCH, and a per-launch key makes every open look like a new device, corrupting
93
- * `min_sessions` in the other direction.
100
+ * ONLY WITH `storage` THAT ACTUALLY WORKED, for the same reason as the sibling hook: without
101
+ * persistence or with an adapter that threw / rejected the auto id is per-LAUNCH, and a
102
+ * per-launch key makes every open look like a new device, corrupting `min_sessions` in the other
103
+ * direction. Both cases arrive here as `undefined`; this function never mints.
94
104
  */
95
105
  const resolveDeviceKey = (
96
- cfg: SessionStartConfig | undefined,
97
106
  opts: UseSessionStartOptions,
107
+ autoDeviceKey: string | undefined,
98
108
  ): string | undefined => {
99
109
  const host = typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
100
110
  if (host) return host;
101
- if (!cfg?.storage) return undefined;
102
- return resolveAutoDeviceKey({ appId: cfg.appId, storage: cfg.storage });
111
+ return autoDeviceKey;
112
+ };
113
+
114
+ /**
115
+ * Resolve the auto `device_key` for ONE app-open, then fire — the fix this hook never got.
116
+ *
117
+ * IT USED TO FIRE SYNCHRONOUSLY. `resolveAutoDeviceKey` is synchronous by contract: it returns a
118
+ * freshly minted id and adopts the persisted one a storage read later. So on a perfectly HEALTHY
119
+ * store this hook stamped a brand-new `wdev_*` on `app.session_started` every single launch,
120
+ * while every other kit surface adopted the persisted id milliseconds afterwards — the exact
121
+ * pre-fix behaviour the sibling hook's mount comment describes. `min_sessions` counts distinct
122
+ * opens grouped by that key, so it was structurally incapable of exceeding 1.
123
+ *
124
+ * The auto path now awaits `hydrateDeviceIdentity` (the provenance-carrying read) and honours the
125
+ * rule `context/deviceId.ts` states for exactly these callers: *"Callers that write a key onto the
126
+ * wire as a cross-launch join must read `durable` and refuse a `false`."* A host-supplied key or a
127
+ * config with no `storage` still fires synchronously — there is nothing to read.
128
+ */
129
+ const openAutoDeviceKey = (fireOpen: (autoDeviceKey: string | undefined) => void): void => {
130
+ const { config: cfg, options: opts } = latest.current;
131
+ const hostKey =
132
+ typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
133
+ if (hostKey || !cfg?.storage) {
134
+ fireOpen(undefined);
135
+ return;
136
+ }
137
+ void hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }).then((identity) => {
138
+ if (cancelled) return;
139
+ fireOpen(identity?.durable ? identity.value : undefined);
140
+ });
103
141
  };
104
142
 
105
- const fire = () => {
143
+ const fire = (autoDeviceKey: string | undefined) => {
106
144
  const { config: cfg, options: opts } = latest.current;
107
145
  if (!cfg?.serverUrl) return;
108
146
  if (opts.enabled === false) return;
@@ -114,7 +152,7 @@ export const useSessionStart = (
114
152
  target,
115
153
  // A fresh per-open id each fire; the emitter's once-guard dedupes within the open.
116
154
  userId: opts.userId,
117
- deviceKey: resolveDeviceKey(cfg, opts),
155
+ deviceKey: resolveDeviceKey(opts, autoDeviceKey),
118
156
  sessionCount: opts.sessionCount,
119
157
  appVersion: cfg.appVersion ?? device.appVersion,
120
158
  platform: Platform.OS,
@@ -124,7 +162,7 @@ export const useSessionStart = (
124
162
  };
125
163
 
126
164
  // 1) Mount = an app-open.
127
- fire();
165
+ openAutoDeviceKey(fire);
128
166
 
129
167
  // 2) Foreground after a real background = a new app-open.
130
168
  let backgroundedAt: number | null = null;
@@ -137,11 +175,15 @@ export const useSessionStart = (
137
175
  if (state === "active") {
138
176
  const since = backgroundedAt;
139
177
  backgroundedAt = null;
140
- if (since != null && Date.now() - since >= BACKGROUND_SESSION_MS) fire();
178
+ // A new open re-runs the same vetting: the auto key must still be a durable one, and a
179
+ // degraded read that released its latches gets a fresh attempt rather than a cached verdict.
180
+ if (since != null && Date.now() - since >= BACKGROUND_SESSION_MS) openAutoDeviceKey(fire);
141
181
  }
142
182
  };
143
183
  const sub = AppState.addEventListener("change", onChange);
144
184
  return () => {
185
+ // A pending hydration must not fire an app-open for a mount that is already gone.
186
+ cancelled = true;
145
187
  // RN >= 0.65 returns a subscription with remove(); guard for older shims.
146
188
  if (sub && typeof (sub as { remove?: () => void }).remove === "function") sub.remove();
147
189
  };
package/src/types.ts CHANGED
@@ -54,10 +54,11 @@ export type OnboardingResult = {
54
54
  /** The raw message thread, for custom downstream parsing. */
55
55
  raw: Message[];
56
56
  /**
57
- * The backend's onboarding plan, when it sent one. Present ONLY on the AI path: a tenant running
58
- * the static flow, or any run the server finished without a plan, leaves this `undefined` AND
59
- * leaves the key off the result object entirely so a host written before plans existed sees
60
- * byte-identically what it always saw.
57
+ * The backend's onboarding plan, present when the backend sent one the kit never infers it
58
+ * from which flow ran. Whether a plan arrives is the backend's configuration, not the kit's. Any
59
+ * run the server finished without a plan leaves this `undefined` AND leaves the key off the
60
+ * result object entirely — so a host written before plans existed sees byte-identically what it
61
+ * always saw.
61
62
  *
62
63
  * ⚠️ The kit does NOT interpret this and does NOT validate it. It checks one structural fact (a
63
64
  * plan is an object) and hands the payload straight through, unread, unlogged, and never attached
@@ -240,10 +241,19 @@ export type WireOnboardingProps = {
240
241
  *
241
242
  * ```tsx
242
243
  * <WireOnboarding userContext={activationJoinContext(deviceKey)} ... />
243
- * // no device id of your own? read the kit's:
244
- * <WireOnboarding userContext={activationJoinContext(resolveAutoDeviceKey({ appId, storage }))} ... />
244
+ * // no device id of your own? pass `storage` and leave this prop alone — the kit injects its
245
+ * // own key, and only after it has confirmed the key actually persists (see `autoJoinKey`).
246
+ * <WireOnboarding config={{ ...config, storage }} ... />
245
247
  * ```
246
248
  *
249
+ * ⛔ Do NOT hand-build the auto key with `activationJoinContext(resolveAutoDeviceKey({...}))`.
250
+ * `resolveAutoDeviceKey` is SYNCHRONOUS by contract: it hands back a freshly minted id and adopts
251
+ * the persisted one a storage read later, and it cannot tell you whether the id survives the
252
+ * launch at all. A key that differs on every launch corrupts `min_sessions` rather than merely
253
+ * leaving the join empty. The auto-join path does the awaited, durability-checked read for you;
254
+ * a host that genuinely wants the value in hand should await `hydrateDeviceIdentity` and refuse a
255
+ * `durable: false` record, which is exactly what the kit does internally.
256
+ *
247
257
  * SINCE 0.12.2, leaving it out no longer silently empties the funnel: when you pass `storage` and
248
258
  * this prop carries no `device_key`, the kit injects its OWN per-install key — the same one the
249
259
  * analytics surfaces mint and persist — so the default wiring joins. Anything you DO pass wins
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * readPlan — lift the backend's onboarding plan off the message thread.
3
3
  *
4
- * WHY IT EXISTS. On the AI path the server appends a SECOND A2A DataPart to the turn it finishes
5
- * on: `{ kind: "onboarding_plan", plan: {...} }`, alongside the component envelope the renderer
6
- * already consumes. The kit carries that payload out through `OnboardingResult.plan` and stops
7
- * there. It does NOT interpret it, does NOT validate its fields, does NOT log it and does NOT
4
+ * WHY IT EXISTS. When it sends a plan, the server appends a SECOND A2A DataPart to the turn it
5
+ * finishes on: `{ kind: "onboarding_plan", plan: {...} }`, alongside the component envelope the
6
+ * renderer already consumes. The kit carries that payload out through `OnboardingResult.plan` and
7
+ * stops there. It does NOT interpret it, does NOT validate its fields, does NOT log it and does NOT
8
8
  * attach it to any event — the plan is user-derived content, and deciding what it MEANS is the
9
9
  * host's job (the kit/host boundary in `ai_rules/context_map.md`: the kit ends at the completion
10
10
  * CTA).
@@ -19,7 +19,10 @@
19
19
  * so a throw here would cost the user the completion of an onboarding they already finished. Every
20
20
  * step below is a runtime-guarded read.
21
21
  *
22
- * The static (non-AI) flow carries no plan at all. That path is unchanged and fully supported.
22
+ * WHICH RUNS CARRY A PLAN IS THE BACKEND'S CONFIGURATION, NOT THE KIT'S. This reader matches the
23
+ * `kind` MARKER below and never the flow that produced the turn, so it needs no knowledge of how
24
+ * the tenant is configured. A run that carries no plan yields `undefined`: unchanged, and fully
25
+ * supported.
23
26
  */
24
27
  import type { Message } from "wireai-rn";
25
28