@wireai/activation 0.12.2 → 0.13.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 (43) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +5 -3
  3. package/dist/analytics/index.d.mts +2 -2
  4. package/dist/analytics/index.d.ts +2 -2
  5. package/dist/analytics/index.js +114 -35
  6. package/dist/analytics/index.js.map +1 -1
  7. package/dist/analytics/index.mjs +114 -36
  8. package/dist/analytics/index.mjs.map +1 -1
  9. package/dist/{currentSession-BxEB37xt.d.ts → currentSession-D7zabMXK.d.ts} +161 -9
  10. package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-_GynvhzT.d.mts} +161 -9
  11. package/dist/index.d.mts +5 -15
  12. package/dist/index.d.ts +5 -15
  13. package/dist/index.js +232 -135
  14. package/dist/index.js.map +1 -1
  15. package/dist/index.mjs +229 -134
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/questionnaire/index.js.map +1 -1
  18. package/dist/questionnaire/index.mjs.map +1 -1
  19. package/dist/reviews/index.js +8 -6
  20. package/dist/reviews/index.js.map +1 -1
  21. package/dist/reviews/index.mjs +8 -6
  22. package/dist/reviews/index.mjs.map +1 -1
  23. package/package.json +1 -1
  24. package/src/OnboardingFlow.tsx +10 -3
  25. package/src/WireOnboarding.tsx +115 -32
  26. package/src/activation/wireActivation.ts +13 -7
  27. package/src/analytics/analyticsFacade.ts +11 -10
  28. package/src/analytics/currentSession.ts +6 -20
  29. package/src/analytics/eventQueue.ts +69 -1
  30. package/src/analytics/index.ts +1 -1
  31. package/src/analytics/reportClientEvent.ts +92 -29
  32. package/src/config/wireConfigFromEnv.ts +1 -10
  33. package/src/context/deviceId.ts +77 -16
  34. package/src/context/userContext.ts +4 -15
  35. package/src/identity/identityRecord.ts +123 -0
  36. package/src/identity/userIdentity.ts +45 -9
  37. package/src/index.ts +6 -4
  38. package/src/session-analytics/useLifecycleEvents.ts +10 -1
  39. package/src/types.ts +14 -0
  40. package/src/utils/deriveAnswers.ts +6 -2
  41. package/src/utils/readProgress.ts +4 -0
  42. package/src/utils/warnInDev.ts +33 -0
  43. package/src/components/DoneBlock.tsx +0 -37
@@ -128,16 +128,71 @@ export const buildEventsRequest = (
128
128
  }
129
129
  };
130
130
 
131
+ /** What the `/v1/events` endpoint says in its 200 body. `errors` is the server's planned per-event
132
+ * reason list; it is read defensively because no deployed server sends it yet. */
133
+ type EventsAck = { written?: number; skipped: number; reasons: string[] };
134
+
135
+ /**
136
+ * Read the `/v1/events` ACK body. Resolves `undefined` when there is nothing readable — no `.json`
137
+ * (an old server, a bare test mock), an already-consumed body, or a hostile response object. NEVER
138
+ * throws, and NEVER dev-gated: the `skipped` count is a RETURN VALUE for the awaitable path, not only
139
+ * a warning, so it has to be read in production too.
140
+ *
141
+ * ⚠️ The body can be read exactly once, so a caller that both decides on `skipped` AND warns must do
142
+ * both from ONE call to this.
143
+ */
144
+ const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
145
+ try {
146
+ const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
147
+ if (typeof json !== "function") return undefined;
148
+ const body = (await Promise.resolve(json.call(res))) as
149
+ | { written?: unknown; skipped?: unknown; errors?: unknown }
150
+ | null
151
+ | undefined;
152
+ const skipped = body?.skipped;
153
+ if (typeof skipped !== "number" || !Number.isFinite(skipped)) return undefined;
154
+ const reasons = Array.isArray(body?.errors)
155
+ ? body.errors
156
+ .map((e) => (e as { reason?: unknown } | null)?.reason)
157
+ .filter((r): r is string => typeof r === "string")
158
+ : [];
159
+ return { written: typeof body?.written === "number" ? body.written : undefined, skipped, reasons };
160
+ } catch {
161
+ // Unreadable / already-consumed body / hostile object — best-effort, treat as "no ack".
162
+ return undefined;
163
+ }
164
+ };
165
+
166
+ /**
167
+ * The DISCARDED warning text, built from what the server ACTUALLY said.
168
+ *
169
+ * It used to staple a cause onto a bare integer — "the usual cause is a missing or empty session_id".
170
+ * Since `ensureCurrentSessionId` shipped, no kit path can emit an event without a `session_id`, so
171
+ * that is now the LEAST likely explanation, and naming it sent every reader looking in the one place
172
+ * the problem is not. The endpoint folds four real failures and two idempotent no-ops into one
173
+ * integer, so unless the server volunteers reasons, the honest thing to report is the count and where
174
+ * the reason lives.
175
+ */
176
+ const describeDiscarded = (ack: EventsAck): string =>
177
+ `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${ack.skipped} event(s) ` +
178
+ "(skipped in the response body) — they are gone, not retried. The server reported: " +
179
+ `skipped=${ack.skipped}${ack.written !== undefined ? `, written=${ack.written}` : ""}` +
180
+ (ack.reasons.length > 0
181
+ ? `, reasons: ${ack.reasons.join(", ")}.`
182
+ : ". It gave no reason (the endpoint folds every rejection into one count), so check the " +
183
+ "server's ingest log for this request rather than guessing.");
184
+
131
185
  /** RN sets this global; absent under node/SSR. Read defensively inside {@link warnOnSkippedEvents}. */
132
186
  declare const __DEV__: boolean | undefined;
133
187
 
134
188
  /**
135
189
  * Read the `/v1/events` ACK body and warn (dev builds only) when the server DISCARDED events.
136
190
  *
137
- * The endpoint answers HTTP **200** with `{ ok, written, skipped }` — an event it refuses (a missing
138
- * `session_id`, a malformed payload) is counted in `skipped`, never surfaced in the status code. Every
139
- * send path here reads `res.ok` alone, so a whole batch can evaporate behind a green response. This
140
- * consumes the body of the PERSISTENT paths (the offline queue + session-start) and names the count.
191
+ * The endpoint answers HTTP **200** with `{ ok, written, skipped }` — an event it refuses is counted
192
+ * in `skipped`, never surfaced in the status code. Every send path here reads `res.ok` alone, so a
193
+ * whole batch can evaporate behind a green response. As of 0.13.0 ALL FOUR send paths consume the ack:
194
+ * the offline queue, session-start, the fire-and-forget POST, and the awaitable one (which also acts
195
+ * on it — see {@link reportClientEventsAwait}).
141
196
  *
142
197
  * LOG ONLY: returns immediately, never throws, and never influences retry / dequeue / return values.
143
198
  * A response with no usable `.json` (an old server, a test mock) is silently ignored.
@@ -149,25 +204,10 @@ declare const __DEV__: boolean | undefined;
149
204
  export const warnOnSkippedEvents = (res: unknown): void => {
150
205
  if (typeof __DEV__ === "undefined" || !__DEV__) return;
151
206
  if (typeof console === "undefined" || !console.warn) return;
152
- try {
153
- const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
154
- if (typeof json !== "function") return;
155
- void Promise.resolve(json.call(res))
156
- .then((body) => {
157
- const skipped = (body as { skipped?: unknown } | null | undefined)?.skipped;
158
- if (typeof skipped !== "number" || skipped <= 0) return;
159
- console.warn(
160
- `[wireai] the server ACCEPTED the /v1/events POST but DISCARDED ${skipped} event(s) ` +
161
- "(skipped in the response body) — they are gone, not retried. The usual cause is an " +
162
- "event with a missing or empty session_id.",
163
- );
164
- })
165
- .catch(() => {
166
- // Unreadable / already-consumed body — best-effort logging, swallow.
167
- });
168
- } catch {
169
- // A hostile response object — swallow.
170
- }
207
+ void readEventsAck(res).then((ack) => {
208
+ if (!ack || ack.skipped <= 0) return;
209
+ console.warn(describeDiscarded(ack));
210
+ });
171
211
  };
172
212
 
173
213
  /**
@@ -182,9 +222,16 @@ export const reportClientEvents = (
182
222
  try {
183
223
  const req = buildEventsRequest(target, events);
184
224
  if (!req) return;
185
- void fetch(req.url, req.init).catch(() => {
186
- // Network/transport error — analytics is best-effort, swallow.
187
- });
225
+ void fetch(req.url, req.init)
226
+ .then((res) => {
227
+ // Still fire-and-forget — nothing is retried, nothing is returned. But a batch that
228
+ // evaporated behind a 200 is exactly what this path used to make invisible, so in dev it
229
+ // now says so, like the queue and session-start paths already did.
230
+ warnOnSkippedEvents(res);
231
+ })
232
+ .catch(() => {
233
+ // Network/transport error — analytics is best-effort, swallow.
234
+ });
188
235
  } catch {
189
236
  // A missing `fetch` — swallow.
190
237
  }
@@ -201,8 +248,17 @@ export const reportClientEvent = (
201
248
  * `/v1/events` path, but resolve only once the server has RESPONDED — so a decision re-fetch fired
202
249
  * immediately after is guaranteed to see the event in the session stream (this is the guarantee
203
250
  * `wire.track` needs before it triggers decision revalidation). Never throws: a missing/invalid
204
- * target, a missing `fetch`, a network error, or a non-2xx status all resolve to `false`. Resolves
205
- * `true` only on a 2xx response.
251
+ * target, a missing `fetch`, a network error, or a non-2xx status all resolve to `false`.
252
+ *
253
+ * ⚠️ 0.13.0: a 2xx is NO LONGER SUFFICIENT. The endpoint answers HTTP 200 with `{ ok, written,
254
+ * skipped }` and counts an event it refuses in `skipped`, so this used to resolve `true` for an event
255
+ * the server had thrown away — and `wire.track` then bumped decision revalidation, making every
256
+ * subscribed gate re-fetch against a stream the action never entered. It now reads the ack and
257
+ * resolves `false` when the server reports a positive `skipped`.
258
+ *
259
+ * BACKWARD COMPATIBLE BY CONSTRUCTION: only an explicit positive `skipped` demotes a 200. An old
260
+ * server that sends no such field, a body that cannot be parsed, or a response with no `.json` at all
261
+ * resolves `true` exactly as before — the change can produce no false negatives.
206
262
  */
207
263
  export const reportClientEventsAwait = async (
208
264
  target: ClientEventTarget | undefined,
@@ -212,7 +268,14 @@ export const reportClientEventsAwait = async (
212
268
  const req = buildEventsRequest(target, events);
213
269
  if (!req) return false;
214
270
  const res = await fetch(req.url, req.init);
215
- return Boolean(res && res.ok);
271
+ if (!res || !res.ok) return false;
272
+ // ONE body read, used for both the verdict and the dev warning — it can only be read once.
273
+ const ack = await readEventsAck(res);
274
+ if (!ack || ack.skipped <= 0) return true;
275
+ if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
276
+ console.warn(describeDiscarded(ack));
277
+ }
278
+ return false;
216
279
  } catch {
217
280
  // Unreachable / missing-fetch / network — best-effort, report failure.
218
281
  return false;
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { detectAppVersion } from "../device/appVersion";
19
19
  import type { WireOnboardingConfig } from "../types";
20
+ import { warnInDev } from "../utils/warnInDev";
20
21
 
21
22
  export type WireConfigOverrides = Partial<WireOnboardingConfig>;
22
23
 
@@ -27,16 +28,6 @@ export const WIRE_ENV_VARS = [
27
28
  "EXPO_PUBLIC_WIREAI_APP_ID",
28
29
  ] as const;
29
30
 
30
- /** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
31
- declare const __DEV__: boolean | undefined;
32
-
33
- /** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests. */
34
- const warnInDev = (message: string): void => {
35
- if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
36
- console.warn(message);
37
- }
38
- };
39
-
40
31
  /**
41
32
  * Minimal `process.env` declaration so the kit stays RN-pure (no `@types/node`).
42
33
  * In RN/Expo, `process.env.EXPO_PUBLIC_*` is provided/inlined by Metro at build
@@ -18,6 +18,8 @@
18
18
  * that verbatim and never mints/persists an auto id.
19
19
  */
20
20
 
21
+ import { resolveIdentity, type IdentityRecord } from "../identity/identityRecord";
22
+
21
23
  /** Prefix so an auto-minted id is visibly the kit's (distinguishable from a host-supplied `deviceKey`). */
22
24
  export const AUTO_DEVICE_ID_PREFIX = "wdev_";
23
25
 
@@ -69,12 +71,21 @@ export const mintDeviceId = (): string => {
69
71
  /** Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle. */
70
72
  const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoDeviceKeys");
71
73
 
74
+ /** What a hydration settled on: the id, and whether persistence actually CONFIRMED it.
75
+ *
76
+ * `durable: false` means the id lives only in this process's memory — the adapter rejected, threw,
77
+ * or there was no adapter at all. That distinction is the whole of K1: a string is a string, so
78
+ * before 0.13.0 a caller could not tell a persisted id from a per-launch mint, and the auto-join
79
+ * gate (`Boolean(storage)`) was reading the PRESENCE of the prop rather than the SUCCESS of the
80
+ * write. See {@link hydrateDeviceIdentity}. */
81
+ type HydrationOutcome = { value: string; durable: boolean };
82
+
72
83
  /** The shared registry: the live id per `appId`, the set of appIds whose hydration already started,
73
84
  * and the in-flight (or settled) hydration promise per `appId` so a waiter can join it. */
74
85
  type AutoDeviceKeyRegistry = {
75
86
  keys: Map<string, string>;
76
87
  hydrating: Set<string>;
77
- pending?: Map<string, Promise<string>>;
88
+ pending?: Map<string, Promise<HydrationOutcome>>;
78
89
  };
79
90
 
80
91
  type GlobalWithDeviceKeys = typeof globalThis & {
@@ -108,40 +119,65 @@ export interface ResolveAutoDeviceKeyOptions {
108
119
  }
109
120
 
110
121
  /**
111
- * Start (or join) the SINGLE-FLIGHT storage read for `appId` and resolve to the id it settles on.
112
- * The promise is parked on the registry so a later `hydrateAutoDeviceKey` awaits the SAME read
113
- * instead of starting a second one. Never rejects: any storage failure resolves to the live id.
122
+ * Start (or join) the SINGLE-FLIGHT storage read for `appId` and resolve to the {@link HydrationOutcome}
123
+ * it settles on. The promise is parked on the registry so a later `hydrateAutoDeviceKey` awaits the
124
+ * SAME read instead of starting a second one. Never rejects: any storage failure resolves to the live
125
+ * id with `durable: false`.
126
+ *
127
+ * TWO OUTCOMES, NOT ONE STRING:
128
+ * • ADOPTED (`durable: true`) — a persisted id was read back, or the freshly minted one was
129
+ * written successfully. The next launch will see the same id.
130
+ * • DEGRADED (`durable: false`) — the adapter rejected, threw, or returned nothing and then failed
131
+ * the write. The id is real but PROCESS-scoped, so anything that
132
+ * counts a device across launches must refuse it.
133
+ *
134
+ * A DEGRADED outcome also drops the registry latches so the NEXT caller starts a fresh read (K8). A
135
+ * cold-boot storage lock is transient; caching it as a verdict for the process lifetime turned a
136
+ * one-second problem into a whole-launch one, and nothing ever retried.
114
137
  */
115
138
  const startHydration = (
116
139
  registry: AutoDeviceKeyRegistry,
117
140
  appId: string,
118
141
  storage: DeviceKeyStorage,
119
142
  minted: string,
120
- ): Promise<string> => {
143
+ ): Promise<HydrationOutcome> => {
121
144
  if (!registry.pending) registry.pending = new Map();
122
145
  const existing = registry.pending.get(appId);
123
146
  if (existing) return existing;
124
147
 
125
148
  const slot = deviceIdStorageKey(appId);
126
- const settled = (): string => registry.keys.get(appId) ?? minted;
127
- let run: Promise<string>;
149
+ const degraded = (): HydrationOutcome => ({ value: registry.keys.get(appId) ?? minted, durable: false });
150
+ const adopted = (value: string): HydrationOutcome => ({ value, durable: true });
151
+ let run: Promise<HydrationOutcome>;
128
152
  try {
129
153
  run = Promise.resolve(storage.getItem(slot))
130
154
  .then((saved) => {
131
155
  const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
132
156
  if (persisted) {
133
157
  registry.keys.set(appId, persisted);
134
- return persisted;
158
+ return adopted(persisted);
135
159
  }
136
160
  // First run on this install: persist the id we just minted so the next launch adopts it.
137
- return Promise.resolve(storage.setItem(slot, minted)).then(settled, settled);
161
+ // ONLY a resolved write earns `durable` — a rejected one leaves the id in memory alone.
162
+ return Promise.resolve(storage.setItem(slot, minted)).then(
163
+ () => adopted(registry.keys.get(appId) ?? minted),
164
+ degraded,
165
+ );
138
166
  })
139
- .catch(settled);
167
+ .catch(degraded);
140
168
  } catch {
141
169
  // A storage adapter that throws synchronously — degrade to the in-memory id.
142
- run = Promise.resolve(settled());
170
+ run = Promise.resolve(degraded());
143
171
  }
144
172
  registry.pending.set(appId, run);
173
+ // Retry-on-failure (K8): release the latches once a degraded outcome settles, so a later caller
174
+ // is not permanently bound to one bad read. Registered AFTER the `set` above, so the clean-up can
175
+ // never race ahead of the entry it is clearing.
176
+ void run.then((outcome) => {
177
+ if (outcome.durable) return;
178
+ registry.pending?.delete(appId);
179
+ registry.hydrating.delete(appId);
180
+ });
145
181
  return run;
146
182
  };
147
183
 
@@ -196,15 +232,40 @@ export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): st
196
232
  */
197
233
  export const hydrateAutoDeviceKey = async (
198
234
  opts: ResolveAutoDeviceKeyOptions = {},
199
- ): Promise<string> => {
235
+ ): Promise<string> => (await hydrateDeviceIdentity(opts))?.value ?? resolveAutoDeviceKey(opts);
236
+
237
+ /**
238
+ * The PROVENANCE-CARRYING sibling of {@link hydrateAutoDeviceKey}: the same awaited read, but it
239
+ * answers "is this id one this install will KEEP?" instead of only "what is the id?".
240
+ *
241
+ * WHY IT EXISTS (K1). `<WireOnboarding>` gated auto-injection on `Boolean(storage)` — the presence of
242
+ * the prop — because a string carries no provenance and there was nothing better to gate on. A
243
+ * REJECTING adapter therefore injected a fresh `wdev_*` on every launch: strictly worse than
244
+ * injecting nothing, since the server counts `min_sessions` by distinct opens grouped on `device_key`,
245
+ * so a per-launch key corrupts that counter AND inflates distinct-device counts. Callers that write a
246
+ * key onto the wire as a cross-launch join must read `durable` and refuse a `false`.
247
+ *
248
+ * Resolves `undefined` only when there is no usable id at all. With no `storage` it resolves
249
+ * immediately with `durable: false` — a process-scoped id is exactly what "no persistence" means.
250
+ * Never throws or rejects.
251
+ */
252
+ export const hydrateDeviceIdentity = async (
253
+ opts: ResolveAutoDeviceKeyOptions = {},
254
+ ): Promise<IdentityRecord | undefined> => {
200
255
  // Mint + register synchronously first, so a waiter and a concurrent sync caller share ONE id.
201
256
  const id = resolveAutoDeviceKey(opts);
202
- if (!opts.storage) return id;
203
- const registry = autoDeviceKeyRegistry();
204
257
  const appId = opts.appId ?? "default";
258
+ const record = (value: string, durable: boolean): IdentityRecord | undefined =>
259
+ resolveIdentity({ value, space: "device", source: "auto", durable, scope: appId });
260
+
261
+ if (!opts.storage) return record(id, false);
262
+ const registry = autoDeviceKeyRegistry();
205
263
  const pending = registry.pending?.get(appId);
206
- if (pending) await pending;
207
- return registry.keys.get(appId) ?? id;
264
+ // No pending entry means a previous hydration already settled DEGRADED and released its latches
265
+ // (see `startHydration`), so the live id is the in-memory one — real, but not durable.
266
+ if (!pending) return record(registry.keys.get(appId) ?? id, false);
267
+ const outcome = await pending;
268
+ return record(outcome.value, outcome.durable);
208
269
  };
209
270
 
210
271
  /** Test-only: forget every auto id + hydration flag so a unit test starts from a clean registry. */
@@ -88,19 +88,6 @@ export interface ResolvedUserContext {
88
88
  userContext?: Record<string, string | number | boolean>;
89
89
  }
90
90
 
91
- /** Reserved `user_context` keys the kit itself writes; host `extra` is namespaced away from these. */
92
- export const RESERVED_USER_CONTEXT_KEYS = [
93
- "device_key",
94
- "app_version",
95
- "app_build",
96
- "network_type",
97
- "session_count",
98
- "returning",
99
- "platform",
100
- "user_email",
101
- "user_email_hashed",
102
- ] as const;
103
-
104
91
  /** The prefix applied to every host `extra` key so it can never collide with a reserved key. */
105
92
  export const EXTRA_KEY_PREFIX = "custom." as const;
106
93
 
@@ -127,8 +114,10 @@ export const hashEmailFnv1a = (email: string): string => {
127
114
  return (hash >>> 0).toString(16).padStart(8, "0");
128
115
  };
129
116
 
130
- /** Trim a candidate string; return `undefined` for a non-string / blank so callers can `if`-gate. */
131
- const cleanString = (value: unknown): string | undefined => {
117
+ /** Trim a candidate string; return `undefined` for a non-string / blank so callers can `if`-gate.
118
+ * Shared with `activation/wireActivation`, which carried a byte-identical private copy named `clean`.
119
+ * Not re-exported from the package barrel — this is an internal helper, not public surface. */
120
+ export const cleanString = (value: unknown): string | undefined => {
132
121
  if (typeof value !== "string") return undefined;
133
122
  const trimmed = value.trim();
134
123
  return trimmed.length > 0 ? trimmed : undefined;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * identityRecord — the ONE provenance-carrying shape for an id the kit puts on the wire.
3
+ *
4
+ * WHY IT EXISTS. `session_id` and `device_key` are bare `string`s minted independently by four
5
+ * subsystems, and nothing anywhere recorded WHERE a given id came from. Every id-layer defect this
6
+ * release fixes is a direct consequence of that one omission:
7
+ *
8
+ * • a rejecting storage adapter's in-memory id was indistinguishable from a persisted one, so the
9
+ * kit injected a fresh per-launch join key on every launch — nothing carried `durable`.
10
+ * • an app-OPEN session id could be posted into a field that means the ONBOARDING session, and the
11
+ * caller was told `true` — nothing carried `space`.
12
+ * • an auto-minted `wdev_*` could be injected beside a device id the host demonstrably owns on
13
+ * another surface, silently — nothing carried `source`.
14
+ *
15
+ * WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT. It is a small record plus a process-wide registry of
16
+ * the ids a HOST supplied. It is NOT a branded-type refactor (`OnboardingSessionId` / `AppSessionId` /
17
+ * `DeviceKey` across every signature) — that is real value and it is deferred, because it touches
18
+ * every file and is not what makes a number correct this week. Nothing here changes the wire.
19
+ *
20
+ * WHY A `Symbol.for` REGISTRY. Same reason as `analytics/currentSession` and `context/deviceId`: tsup
21
+ * inlines a separate copy of a module into each bundle (`.` and `./analytics`), so a plain module
22
+ * `let` would give every bundle its own registry and the cross-surface question this exists to answer
23
+ * ("did ANY surface in this process get a host-supplied device key?") would read `no` from the wrong
24
+ * copy. `Symbol.for` resolves to one slot on `globalThis` no matter how many copies exist.
25
+ */
26
+
27
+ /**
28
+ * Which id space a value belongs to. These are NOT interchangeable, and the whole point of naming
29
+ * them is that a value from one space must never be posted into a field that means another:
30
+ * • `onboarding-session` — the A2A `contextId` for ONE onboarding run.
31
+ * • `app-session` — the per-app-open session id (`app.session_started`).
32
+ * • `device` — the per-install `device_key`; the only cross-family join key.
33
+ */
34
+ export type IdentitySpace = "onboarding-session" | "app-session" | "device";
35
+
36
+ /** Where the value came from: the host handed it over, or the kit minted it. */
37
+ export type IdentitySource = "host" | "auto";
38
+
39
+ /** An id plus everything a consumer needs to decide whether it may use it. */
40
+ export type IdentityRecord = {
41
+ /** The id itself, trimmed. Never empty (a blank input yields no record at all). */
42
+ value: string;
43
+ /** Which id space {@link value} belongs to. */
44
+ space: IdentitySpace;
45
+ /** `host` = the integrator supplied it; `auto` = the kit minted it. */
46
+ source: IdentitySource;
47
+ /**
48
+ * Whether the value was actually PERSISTED (or adopted from persistence), as opposed to living
49
+ * only in this process's memory. A non-durable auto id is a DIFFERENT id on the next launch, which
50
+ * for a `device` value is worse than no value at all: the server counts `min_sessions` by distinct
51
+ * opens grouped on `device_key`, so a per-launch key corrupts the counter rather than leaving it
52
+ * empty. A host-supplied value is durable by definition — the host owns its lifetime.
53
+ */
54
+ durable: boolean;
55
+ };
56
+
57
+ /** Input to {@link resolveIdentity}. `value` is `unknown` so callers can pass a raw prop through. */
58
+ export type ResolveIdentityInput = {
59
+ value: unknown;
60
+ space: IdentitySpace;
61
+ source: IdentitySource;
62
+ /** Defaults to `true` for a host value (the host owns its lifetime) and `false` otherwise. */
63
+ durable?: boolean;
64
+ /** Tenant/app id — two tenants in one process never share a provenance entry. */
65
+ scope?: string;
66
+ };
67
+
68
+ /** Well-known key into the runtime-global symbol registry — one provenance registry per process. */
69
+ const IDENTITY_PROVENANCE_SLOT: unique symbol = Symbol.for("@wireai/activation:identityProvenance");
70
+
71
+ /** `"<space>:<scope>"` → the HOST-supplied value seen for it. Auto values are never recorded. */
72
+ type ProvenanceRegistry = { host: Map<string, string> };
73
+
74
+ type GlobalWithProvenance = typeof globalThis & {
75
+ [IDENTITY_PROVENANCE_SLOT]?: ProvenanceRegistry;
76
+ };
77
+
78
+ const provenanceGlobal = globalThis as GlobalWithProvenance;
79
+
80
+ const provenanceRegistry = (): ProvenanceRegistry => {
81
+ const existing = provenanceGlobal[IDENTITY_PROVENANCE_SLOT];
82
+ if (existing) return existing;
83
+ const created: ProvenanceRegistry = { host: new Map() };
84
+ provenanceGlobal[IDENTITY_PROVENANCE_SLOT] = created;
85
+ return created;
86
+ };
87
+
88
+ const provenanceKey = (space: IdentitySpace, scope?: string): string =>
89
+ `${space}:${scope ?? "default"}`;
90
+
91
+ /**
92
+ * Build an {@link IdentityRecord} from a candidate value, or `undefined` when there is nothing usable
93
+ * (a non-string, or blank after trimming) — so a caller can `if (record)`-gate instead of guessing
94
+ * whether an empty string means "none" or "not yet".
95
+ *
96
+ * SIDE EFFECT, deliberate and the reason this is a function and not an object literal: a `host`-sourced
97
+ * record is RECORDED on the process registry, so a later surface can ask {@link hostIdentity} whether
98
+ * this process demonstrably owns a host id in that space. That is what turns "the kit injected its own
99
+ * key" from a silent third id space into a warnable condition. Never throws.
100
+ */
101
+ export const resolveIdentity = (input: ResolveIdentityInput): IdentityRecord | undefined => {
102
+ if (typeof input.value !== "string") return undefined;
103
+ const value = input.value.trim();
104
+ if (!value) return undefined;
105
+ const durable = input.durable ?? input.source === "host";
106
+ if (input.source === "host") {
107
+ provenanceRegistry().host.set(provenanceKey(input.space, input.scope), value);
108
+ }
109
+ return { value, space: input.space, source: input.source, durable };
110
+ };
111
+
112
+ /**
113
+ * The HOST-supplied id this process has seen for a space, or `undefined` when every surface so far
114
+ * let the kit mint its own. Answers the cross-surface question no single mount can answer alone:
115
+ * "does this app own a device id that this particular mount was not given?"
116
+ */
117
+ export const hostIdentity = (space: IdentitySpace, scope?: string): string | undefined =>
118
+ provenanceRegistry().host.get(provenanceKey(space, scope));
119
+
120
+ /** Test-only: forget every recorded host identity so a unit test starts from a clean registry. */
121
+ export const resetIdentityProvenance = (): void => {
122
+ provenanceRegistry().host.clear();
123
+ };
@@ -76,20 +76,49 @@ export type IdentifyOnboardingOptions = {
76
76
  appId?: string;
77
77
  /** Storage key override — pass the same `persistKey` you gave `<WireOnboarding>`, if any. */
78
78
  persistKey?: string;
79
+ /**
80
+ * OPT-IN LAST RESORT, default `false`. When no ONBOARDING session can be resolved (no `contextId`,
81
+ * nothing in `storage`), bind the user to the LIVE PER-OPEN app session instead and return
82
+ * `"app_session"`.
83
+ *
84
+ * ⚠️ These are two different id spaces sharing one wire field. An onboarding session id is the A2A
85
+ * `contextId`; a per-open id is what `app.session_started` registers. The server's onboarding funnel
86
+ * groups by `session_id`, so a per-open id posted here does not attach the user to their onboarding
87
+ * — it writes a row nothing in that funnel can join. Until 0.13.0 this happened SILENTLY and
88
+ * returned `true`, in exactly the documented post-completion case (completion clears the persisted
89
+ * session), so the funnel stayed unattributed while the host was told it had worked.
90
+ *
91
+ * Turn it on only if binding the id to *some* session the server saw is genuinely worth more to you
92
+ * than knowing the onboarding bind failed — and read the return value, which now says which it was.
93
+ */
94
+ allowAppSessionFallback?: boolean;
79
95
  };
80
96
 
97
+ /**
98
+ * What {@link identifyOnboarding} bound, and to WHICH id space — because `true` could not say.
99
+ *
100
+ * • `"onboarding"` — bound to the A2A `contextId`. This is the one that attributes the funnel.
101
+ * • `"app_session"` — bound to the live per-open app session, via `allowAppSessionFallback`. The
102
+ * server saw that session, but it is not this user's onboarding.
103
+ * • `false` — nothing was dispatched (no user id, no server url, no resolvable session).
104
+ *
105
+ * ⚠️ 0.13.0 widened this from `boolean`. `"onboarding"` is truthy, so an `if (await identify…)` still
106
+ * behaves identically; only an explicit `: boolean` annotation needs updating.
107
+ */
108
+ export type IdentifyOnboardingBinding = "onboarding" | "app_session" | false;
109
+
81
110
  /**
82
111
  * Attach a host user id to an onboarding session AFTER the fact (post-registration), by sending
83
112
  * an `identify` client event to `/v1/events`. Resolves the contextId from an explicit
84
113
  * `contextId` or, failing that, from the persisted session in the host `storage`.
85
114
  *
86
- * Fire-and-forget under the hood (never throws, never blocks onboarding). Resolves to `true`
87
- * when an identify event was dispatched, `false` when it couldn't (no user id, no server url,
88
- * or no resolvable contextId).
115
+ * Fire-and-forget under the hood (never throws, never blocks onboarding). Resolves to the
116
+ * {@link IdentifyOnboardingBinding} that says WHICH id space was bound, or `false` when nothing could
117
+ * be (no user id, no server url, or no resolvable session).
89
118
  */
90
119
  export const identifyOnboarding = async (
91
120
  opts: IdentifyOnboardingOptions,
92
- ): Promise<boolean> => {
121
+ ): Promise<IdentifyOnboardingBinding> => {
93
122
  const userId = sanitizeUserId(opts.userId);
94
123
  if (!userId || !opts.config?.serverUrl) return false;
95
124
 
@@ -101,15 +130,22 @@ export const identifyOnboarding = async (
101
130
  contextId = stored?.id;
102
131
  }
103
132
  }
104
- // Last resort: bind to the LIVE per-open session (registered by `reportSessionStart`) so a
105
- // post-flow identify with no captured contextId still attaches to a session the server saw,
106
- // instead of no-oping. The onboarding contextId (above) is still preferred when available.
107
- if (!contextId) contextId = getCurrentSessionId();
133
+ let space: Exclude<IdentifyOnboardingBinding, false> = "onboarding";
134
+ // The OPT-IN last resort (K3). Until 0.13.0 this ran unconditionally: with no captured contextId
135
+ // it posted the LIVE PER-OPEN session id in the `session_id` field which on this endpoint means
136
+ // the ONBOARDING session — and then returned `true`. Two disjoint id spaces share that field, so
137
+ // the row it wrote could never join the onboarding funnel, and the host got a success signal for a
138
+ // bind that had not happened. It is now off unless the caller asks, and when it does fire it says
139
+ // so in the return value instead of impersonating an onboarding bind.
140
+ if (!contextId && opts.allowAppSessionFallback) {
141
+ contextId = getCurrentSessionId();
142
+ space = "app_session";
143
+ }
108
144
  if (!contextId) return false;
109
145
 
110
146
  reportClientEvent(
111
147
  { serverUrl: opts.config.serverUrl, apiKey: opts.config.apiKey },
112
148
  { event_type: "identify", session_id: contextId, user_id: userId },
113
149
  );
114
- return true;
150
+ return space;
115
151
  };
package/src/index.ts CHANGED
@@ -30,8 +30,6 @@ export { LoadingBlock } from "./components/LoadingBlock";
30
30
  export { LoadingScreen } from "./components/LoadingScreen";
31
31
  export { AnimatedSparkle } from "./components/AnimatedSparkle";
32
32
  export { ErrorBlock } from "./components/ErrorBlock";
33
- /** @deprecated No longer used internally — the terminal screen is CompletionView. */
34
- export { DoneBlock } from "./components/DoneBlock";
35
33
  export { CompletionView } from "./components/CompletionView";
36
34
  export { IllustrationProvider, useIllustration } from "./components/Illustration";
37
35
  export type { IllustrationRegistry } from "./components/Illustration";
@@ -147,7 +145,9 @@ export { detectNativeModel } from "./device/deviceModel";
147
145
 
148
146
  // ─── User identity (opaque pseudonymous id; late binding, dependency-free) ────
149
147
  export { identifyOnboarding, sanitizeUserId, looksLikeEmail, USER_ID_MAX_LENGTH } from "./identity/userIdentity";
150
- export type { IdentifyOnboardingOptions } from "./identity/userIdentity";
148
+ export type { IdentifyOnboardingOptions, IdentifyOnboardingBinding } from "./identity/userIdentity";
149
+ export { resolveIdentity, hostIdentity, resetIdentityProvenance } from "./identity/identityRecord";
150
+ export type { IdentityRecord, IdentitySpace, IdentitySource } from "./identity/identityRecord";
151
151
 
152
152
  // ─── Rich user context (one object → every event's user_context; opt-in email PII) ────
153
153
  export {
@@ -159,7 +159,6 @@ export {
159
159
  clearPiiFromContext,
160
160
  analyticsUserIdStorageKey,
161
161
  activationJoinContext,
162
- RESERVED_USER_CONTEXT_KEYS,
163
162
  EXTRA_KEY_PREFIX,
164
163
  } from "./context/userContext";
165
164
  export type {
@@ -182,6 +181,9 @@ export {
182
181
  // The awaitable sibling: resolves AFTER the persisted id has been read back, for a caller that can
183
182
  // afford one storage read and must not stamp a key minted a millisecond ago (the lifecycle mount).
184
183
  hydrateAutoDeviceKey,
184
+ // The PROVENANCE-carrying form of the same read: it also says whether the id was actually persisted,
185
+ // so a caller writing a cross-launch join key onto the wire can refuse a per-launch one.
186
+ hydrateDeviceIdentity,
185
187
  resetAutoDeviceKeys,
186
188
  } from "./context/deviceId";
187
189
  export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "./context/deviceId";
@@ -33,6 +33,7 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
33
33
  import { createEventQueue, type EnvelopeSource, type EventQueue } from "../analytics/eventQueue";
34
34
  import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
35
35
  import { hydrateAutoDeviceKey, resolveAutoDeviceKey } from "../context/deviceId";
36
+ import { resolveIdentity } from "../identity/identityRecord";
36
37
  import { collectDeviceContext } from "../device/deviceContext";
37
38
  import type { WireOnboardingStorage } from "../session/persistedSession";
38
39
  import { reportFirstOpen } from "./lifecycle";
@@ -137,7 +138,15 @@ export const useLifecycleEvents = (
137
138
  cfg: LifecycleConfig | undefined,
138
139
  opts: UseLifecycleEventsOptions,
139
140
  ): string | undefined => {
140
- const host = typeof opts.deviceKey === "string" && opts.deviceKey.trim() ? opts.deviceKey : undefined;
141
+ // A HOST-supplied key is recorded on the process provenance registry, so a `<WireOnboarding>`
142
+ // mount that was not given one can tell "this app owns no device id" from "this app owns one
143
+ // and forgot it there" — the silent third id space (K9). Recording only; nothing reads it here.
144
+ const host = resolveIdentity({
145
+ value: opts.deviceKey,
146
+ space: "device",
147
+ source: "host",
148
+ scope: cfg?.appId,
149
+ })?.value;
141
150
  if (host) return host;
142
151
  if (!cfg?.storage) return undefined;
143
152
  return resolveAutoDeviceKey({ appId: cfg.appId, storage: cfg.storage });