@wireai/activation 0.13.6 → 0.14.1

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 (47) hide show
  1. package/AGENTS.md +21 -9
  2. package/CHANGELOG.md +227 -1
  3. package/INTEGRATION_PROMPT.md +7 -4
  4. package/README.md +15 -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 +31 -7
  14. package/dist/index.d.ts +31 -7
  15. package/dist/index.js +643 -539
  16. package/dist/index.js.map +1 -1
  17. package/dist/index.mjs +643 -539
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/questionnaire/index.js +14 -6
  20. package/dist/questionnaire/index.js.map +1 -1
  21. package/dist/questionnaire/index.mjs +14 -6
  22. package/dist/questionnaire/index.mjs.map +1 -1
  23. package/dist/reviews/index.js +14 -6
  24. package/dist/reviews/index.js.map +1 -1
  25. package/dist/reviews/index.mjs +14 -6
  26. package/dist/reviews/index.mjs.map +1 -1
  27. package/llms.txt +2 -2
  28. package/package.json +1 -1
  29. package/src/OnboardingFlow.tsx +4 -3
  30. package/src/WireOnboarding.tsx +48 -8
  31. package/src/activation/useWireActivation.ts +14 -1
  32. package/src/activation/wireActivation.ts +68 -2
  33. package/src/analytics/analyticsFacade.ts +24 -0
  34. package/src/analytics/currentSession.ts +3 -2
  35. package/src/analytics/eventQueue.ts +106 -11
  36. package/src/analytics/reportClientEvent.ts +31 -7
  37. package/src/analytics/useAnalytics.ts +17 -0
  38. package/src/context/deviceId.ts +10 -3
  39. package/src/permissions/permissionMemory.ts +41 -5
  40. package/src/reviews/runtime.ts +75 -23
  41. package/src/session/persistedSession.ts +32 -8
  42. package/src/session-analytics/lifecycle.ts +26 -5
  43. package/src/session-analytics/reportSessionStart.ts +14 -10
  44. package/src/session-analytics/useLifecycleEvents.ts +70 -32
  45. package/src/session-analytics/useSessionStart.ts +57 -15
  46. package/src/types.ts +16 -6
  47. package/src/utils/readPlan.ts +8 -5
@@ -15,6 +15,7 @@
15
15
  * error. A broken storage adapter must never gate or break onboarding.
16
16
  */
17
17
  import {
18
+ READ_TIMED_OUT,
18
19
  READ_TIMEOUT_MS,
19
20
  withTimeout,
20
21
  type WireOnboardingStorage,
@@ -47,19 +48,54 @@ export const readSettledPermissions = (
47
48
  }
48
49
  };
49
50
 
50
- /** Read the settled ids for `sessionId`. Never throws, never hangs past the shared read ceiling. */
51
- export const loadSettledPermissions = async (
51
+ /**
52
+ * Did the store ANSWER, or did it not? A read that timed out or threw is `unknown` — which is NOT
53
+ * the same fact as "this session has settled nothing", even though both used to arrive as `[]`.
54
+ *
55
+ * The distinction is load-bearing in two directions. A caller that treats `unknown` as empty
56
+ * (a) re-asks a permission the OS grants exactly once, and (b) persists a set it grew from that
57
+ * `[]`, SHRINKING the stored record to a single id. A successful read of a corrupt, absent, or
58
+ * other-session entry is a genuine `[]`: the store answered, and there is nothing here for us.
59
+ */
60
+ export type SettledPermissionsOutcome =
61
+ | { status: "read"; ids: string[] }
62
+ | { status: "unknown" };
63
+
64
+ /**
65
+ * Read the settled ids for `sessionId`, reporting WHETHER the store answered. Never throws, never
66
+ * hangs past the shared read ceiling.
67
+ */
68
+ export const loadSettledPermissionsOutcome = async (
52
69
  storage: WireOnboardingStorage,
53
70
  key: string,
54
71
  sessionId: string,
55
- ): Promise<string[]> => {
72
+ ): Promise<SettledPermissionsOutcome> => {
56
73
  try {
57
- return readSettledPermissions(await withTimeout(storage.getItem(key), READ_TIMEOUT_MS), sessionId);
74
+ const raw = await withTimeout(storage.getItem(key), READ_TIMEOUT_MS);
75
+ if (raw === READ_TIMED_OUT) return { status: "unknown" };
76
+ return { status: "read", ids: readSettledPermissions(raw, sessionId) };
58
77
  } catch {
59
- return [];
78
+ // The adapter threw or rejected: the store did not answer, so we know nothing either way.
79
+ return { status: "unknown" };
60
80
  }
61
81
  };
62
82
 
83
+ /**
84
+ * Read the settled ids for `sessionId`, collapsing "the store did not answer" to `[]`.
85
+ *
86
+ * Kept as the module's simple reader for callers that have no way to act on the difference. The
87
+ * one caller that CAN — `WireOnboarding`, which would otherwise re-ask and then overwrite the
88
+ * stored record — uses {@link loadSettledPermissionsOutcome} instead.
89
+ */
90
+ export const loadSettledPermissions = async (
91
+ storage: WireOnboardingStorage,
92
+ key: string,
93
+ sessionId: string,
94
+ ): Promise<string[]> => {
95
+ const outcome = await loadSettledPermissionsOutcome(storage, key, sessionId);
96
+ return outcome.status === "read" ? outcome.ids : [];
97
+ };
98
+
63
99
  /** Persist the settled ids for `sessionId` - fire-and-forget, all errors swallowed. */
64
100
  export const saveSettledPermissions = (
65
101
  storage: WireOnboardingStorage,
@@ -125,7 +125,7 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
125
125
  // for the same reason `currentSession` does: tsup inlines this module into several bundles and a
126
126
  // plain module-local `let` would give each bundle its own "process".
127
127
  //
128
- // ── WHY THE UNIT IS PINNED FOR THE WHOLE LAUNCH ──────────────────────────────────────────────
128
+ // ── WHY THE UNIT IS PINNED, AND WHEN IT IS ALLOWED TO MOVE ───────────────────────────────────
129
129
  // The first shape of this function read the two tiers LIVE on every call: the registered session id
130
130
  // when there was one, else the process id. That let the UNIT change mid-launch, and React's own
131
131
  // ordering guarantees it does. The gates call `bumpSessionCount` from a `useState` INITIALIZER,
@@ -137,39 +137,91 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
137
137
  // remount: gate initializer → SESSION id ≠ the stored open → count = 2
138
138
  //
139
139
  // Two "sessions" inside one app open, which makes the fail-closed `minSessions: 2` default (added
140
- // after the 2026-07-16 one-star incident) satisfiable in the very launch it exists to guard. So the
141
- // FIRST read pins whatever it resolved into the process slot and every later read returns that,
142
- // regardless of what the session registry does afterwards. The client counter only has to be
143
- // monotone and per-launch; the server's own `min_sessions` still counts real `app.session_started`
144
- // events, so nothing downstream needs the two ids to be identical.
140
+ // after the 2026-07-16 one-star incident) satisfiable in the very launch it exists to guard.
145
141
  //
146
- // @globalSlot LATCH pinned by the FIRST read and stable for the whole launch. This is the one
147
- // slot whose latch is the fix: it samples the LIVE `currentSessionId` once, on purpose, so that
148
- // registry changing underneath it (render before effect, on every cold start) cannot change the
149
- // unit mid-launch and hand `minSessions: 2` a second "session" inside one app open. A second
150
- // differing write is therefore not a stale-cache bug, it IS the defect see the block above.
142
+ // THE FIRST FIX PINNED THE WHOLE LAUNCH one sample, never revisited. That closed the cold start
143
+ // and opened the opposite defect. `useLifecycleEvents` defines an app-open as a foreground after 30
144
+ // minutes
145
+ // in the background and fires a fresh `app.session_started` for it, but on iOS an app is SUSPENDED,
146
+ // not killed, so ONE JS process outlives many opens. A user who opens the app daily for a week
147
+ // produces seven server-side opens while `wire_review_<id>_sessions` stays at 1, and the local
148
+ // `minSessions: 2` rule is unsatisfiable on a phone that is never force-quit — which is most
149
+ // phones. It failed in the SAFE direction (a prompt that never shows, not one that shows too
150
+ // early), but the counter's own docstring promised "once per APP-OPEN" and did not deliver it.
151
+ //
152
+ // THE DISTINCTION THAT RESOLVES BOTH. The two ids in the cold-start trace name the SAME open; a
153
+ // LATER registration names a DIFFERENT one. Nothing else has to change: remember the registered id
154
+ // that was live when the pin was taken, and read it lazily.
155
+ //
156
+ // • pin unset → pin = the registered id ?? a freshly minted one, and remember which
157
+ // registered id that was (`undefined` when we had to mint).
158
+ // • remembered `undefined` → the FIRST registration merely NAMES the open the gate already
159
+ // counted under a minted id. Adopt it, leave the PIN alone. This is
160
+ // the cold-start case, and it stays shut.
161
+ // • live id ≠ remembered → a genuinely new app-open. Re-pin to it; the counter bumps once.
162
+ //
163
+ // ⚠️ ONE ACCEPTED MISS, deliberately in the safe direction: if the mount open never registers (a
164
+ // refused non-durable device key over a broken store) and a LATER foreground is the first
165
+ // registration the launch ever sees, that registration is ADOPTED rather than counted, costing one
166
+ // open on an already storage-degraded device. Under-counting is the only direction a fail-closed
167
+ // gate may err in, so this is accepted rather than chased.
168
+ //
169
+ // The client counter only has to be monotone and to move once per real open; the server's own
170
+ // `min_sessions` still counts real `app.session_started` events, so nothing downstream needs the
171
+ // two ids to be identical.
172
+ //
173
+ // @globalSlot LIVE — it re-pins on a genuinely new app-open, so a reader that caches the returned
174
+ // string across opens re-freezes the counter at 1, and the local minSessions rule stops advancing
175
+ // forever on a suspended-not-killed app. The slot holds a PAIR rather than a bare id, because "is this a new
176
+ // open?" is only answerable against the registered id that was live when the pin was taken.
151
177
  const PROCESS_OPEN_ID_SLOT: unique symbol = Symbol.for("@wireai/activation:processOpenId");
152
178
 
153
- type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: string };
179
+ /** The pinned open id plus the registered session id that was live at the moment it was pinned. */
180
+ type OpenPin = {
181
+ /** The id gate counting treats as THIS app-open. Never empty. */
182
+ openId: string;
183
+ /** The registered session id observed at pin time; `undefined` when `openId` was minted. */
184
+ observed: string | undefined;
185
+ };
186
+
187
+ type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: OpenPin };
154
188
 
155
189
  const openIdGlobal = globalThis as GlobalWithOpenId;
156
190
 
157
191
  /**
158
- * The id identifying THIS app-open for gate counting, PINNED on first read for the whole launch:
159
- * the live per-open `session_id` if one was already registered when the first gate asked, else a
160
- * minted per-process id. Stable from the first render to the last. Never empty.
192
+ * The id identifying THIS app-open for gate counting: the live per-open `session_id` once one has
193
+ * been registered, else a minted per-process id. Stable for the whole of an app-open and across
194
+ * every remount inside it, and it moves exactly once when a genuinely new open is registered.
195
+ * Never empty.
161
196
  */
162
197
  export const currentOpenId = (): string => {
163
- const existing = openIdGlobal[PROCESS_OPEN_ID_SLOT];
164
- if (existing) return existing;
165
- // Adopt the registered session id when the host wired lifecycle BEFORE any gate rendered; that is
166
- // the same unit the server counts. Otherwise mint one. Either way it is pinned from here on.
167
- const resolved = getCurrentSessionId() ?? makeSessionId();
168
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = resolved;
169
- return resolved;
198
+ const live = getCurrentSessionId();
199
+ const pin = openIdGlobal[PROCESS_OPEN_ID_SLOT];
200
+
201
+ // First read of the launch. Adopt the registered id when the host wired lifecycle BEFORE any gate
202
+ // rendered that is the same unit the server counts — otherwise mint one and record that we did.
203
+ if (!pin) {
204
+ const openId = live ?? makeSessionId();
205
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live };
206
+ return openId;
207
+ }
208
+
209
+ // Nothing registered yet, or still the same registered open: the pin stands.
210
+ if (!live || live === pin.observed) return pin.openId;
211
+
212
+ // The first registration of the launch NAMES the open we already pinned under a minted id, so it
213
+ // is adopted, not counted. Re-pinning here is exactly the cold-start double-count described above.
214
+ if (pin.observed === undefined) {
215
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: pin.openId, observed: live };
216
+ return pin.openId;
217
+ }
218
+
219
+ // A second, DIFFERENT registration is a genuinely new app-open.
220
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live };
221
+ return live;
170
222
  };
171
223
 
172
- /** Test-only: forget the process open id so a unit test starts from a clean launch. */
224
+ /** Test-only: forget the pinned open so a unit test starts from a clean launch. */
173
225
  export const resetProcessOpenId = (): void => {
174
226
  openIdGlobal[PROCESS_OPEN_ID_SLOT] = undefined;
175
227
  };
@@ -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