@wireai/activation 0.14.3 → 0.16.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 (85) hide show
  1. package/AGENTS.md +96 -21
  2. package/CHANGELOG.md +780 -0
  3. package/INTEGRATION_PROMPT.md +61 -23
  4. package/README.md +110 -31
  5. package/dist/analytics/index.d.mts +35 -13
  6. package/dist/analytics/index.d.ts +35 -13
  7. package/dist/analytics/index.js +288 -127
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +288 -127
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/coachmarks/index.d.mts +15 -1
  12. package/dist/coachmarks/index.d.ts +15 -1
  13. package/dist/coachmarks/index.js +120 -22
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +120 -22
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-CUvTOchb.d.mts → currentSession-Bz7G6lno.d.mts} +35 -39
  18. package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-z-CZ55ad.d.ts} +35 -39
  19. package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
  20. package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
  21. package/dist/index.d.mts +5 -2
  22. package/dist/index.d.ts +5 -2
  23. package/dist/index.js +125 -36
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.mjs +125 -36
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/questionnaire/index.d.mts +3 -16
  28. package/dist/questionnaire/index.d.ts +3 -16
  29. package/dist/questionnaire/index.js +202 -46
  30. package/dist/questionnaire/index.js.map +1 -1
  31. package/dist/questionnaire/index.mjs +203 -47
  32. package/dist/questionnaire/index.mjs.map +1 -1
  33. package/dist/reviews/index.d.mts +10 -6
  34. package/dist/reviews/index.d.ts +10 -6
  35. package/dist/reviews/index.js +269 -52
  36. package/dist/reviews/index.js.map +1 -1
  37. package/dist/reviews/index.mjs +270 -53
  38. package/dist/reviews/index.mjs.map +1 -1
  39. package/dist/showcase/index.d.mts +1 -1
  40. package/dist/showcase/index.d.ts +1 -1
  41. package/dist/showcase/index.js +106 -20
  42. package/dist/showcase/index.js.map +1 -1
  43. package/dist/showcase/index.mjs +107 -21
  44. package/dist/showcase/index.mjs.map +1 -1
  45. package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
  46. package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
  47. package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
  48. package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
  49. package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
  50. package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
  51. package/llms.txt +9 -9
  52. package/package.json +6 -9
  53. package/src/WireOnboarding.tsx +4 -1
  54. package/src/analytics/currentSession.ts +141 -4
  55. package/src/analytics/index.ts +6 -1
  56. package/src/analytics/reportClientEvent.ts +19 -10
  57. package/src/analytics/useAnalytics.ts +74 -15
  58. package/src/analytics/wireDoctor.ts +152 -7
  59. package/src/coachmarks/CoachmarkProvider.tsx +26 -5
  60. package/src/coachmarks/runtime.ts +156 -2
  61. package/src/coachmarks/types.ts +7 -2
  62. package/src/coachmarks/useCoachmarkTour.ts +51 -1
  63. package/src/context/deviceId.ts +72 -6
  64. package/src/features/WireFeaturesProvider.tsx +72 -12
  65. package/src/features/fetchWireFeatures.ts +49 -11
  66. package/src/features/useWireFeatures.ts +39 -3
  67. package/src/identity/identityRecord.ts +15 -2
  68. package/src/questionnaire/QuestionnaireGate.tsx +40 -1
  69. package/src/questionnaire/transport.ts +22 -8
  70. package/src/questionnaire/useQuestionnaireGate.ts +58 -7
  71. package/src/reviews/ReviewGate.tsx +125 -37
  72. package/src/reviews/decision.ts +11 -1
  73. package/src/reviews/idempotency.ts +109 -0
  74. package/src/reviews/index.ts +4 -2
  75. package/src/reviews/runtime.ts +44 -13
  76. package/src/reviews/transport.ts +39 -20
  77. package/src/reviews/types.ts +7 -0
  78. package/src/reviews/useReviewGate.ts +57 -7
  79. package/src/session-analytics/lifecycle.ts +16 -0
  80. package/src/session-analytics/useLifecycleEvents.ts +30 -2
  81. package/src/session-analytics/useSessionStart.ts +22 -2
  82. package/src/showcase/FeatureShowcase.tsx +50 -3
  83. package/src/types.ts +15 -8
  84. package/src/utils/submitResult.ts +17 -9
  85. package/src/utils/withDeadline.ts +70 -0
@@ -1,4 +1,4 @@
1
- import { k as ReviewTarget, S as SubmitResult, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-Cju-1_jT.mjs';
1
+ import { k as ReviewTarget, S as SubmitResult, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-Byx306Kv.mjs';
2
2
 
3
3
  /**
4
4
  * What became of a review POST — the SHARED three-outcome verdict, re-exported under the name the
@@ -25,23 +25,28 @@ type ReviewSubmitResult = SubmitResult;
25
25
  *
26
26
  * It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
27
27
  * "nothing reached the server" and "the server answered non-2xx", and the one caller that reads
28
- * this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. But the server mints
29
- * its own row id (`create_review` / `_new_id()`) and `CreateReviewRequest` carries no id, so there
30
- * is NO idempotency key on the wire: a 502 returned AFTER the insert commits means the re-post
31
- * writes a SECOND row, double-counting `count` and corrupting `avg` the precise corruption the
32
- * one-row latch exists to prevent. A response of any status proves the server was reached, and
33
- * that is a different question from whether it liked the row. So the two are different values.
28
+ * this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. At the time there was
29
+ * no idempotency key on the wire at all, so a 502 returned AFTER the insert commits meant the
30
+ * re-post wrote a SECOND row, double-counting `count` and corrupting `avg` the precise corruption
31
+ * the one-row latch exists to prevent. A response of any status proves the server was reached, and
32
+ * that is a different question from whether it liked the row. So the two are different values, and
33
+ * they stay different: the split is what lets the caller decide, and the retry-on-`rejected`
34
+ * question is a recovery-budget decision the key does not answer on its own.
34
35
  *
35
36
  * ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
36
37
  *
37
38
  * `unsent` is not proof the server never got the row. A connection dropped after the request was
38
39
  * written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
39
- * an offline device. Retrying only on `unsent` is therefore SAFER, not SAFE: it removes the
40
- * double-post the server itself told us about, and leaves the narrow window where the answer never
41
- * made it back onto the wire. Closing that window needs a CLIENT-MINTED IDEMPOTENCY KEY the server
42
- * upserts on, which is a server change (`CreateReviewRequest` + `create_review`) and is not
43
- * something the kit can fake. Until it exists, prefer losing a row over inventing one: a lost
44
- * detractor is a gap in the data, a duplicated one is a lie in the data.
40
+ * an offline device. That used to make the `unsent` re-post SAFER rather than SAFE, and it is what
41
+ * the client-minted idempotency key now covers: `ReviewSubmission.idempotency_key` reaches
42
+ * `CreateReviewRequest`, and the server upserts the derived row rather than inserting a second one.
43
+ *
44
+ * IT IS A CAPABILITY, NOT A GUARANTEE, and the condition is not ours to enforce from in here. The
45
+ * server honours the key only when the post also carries a unit it can scope it to
46
+ * `meta.device_key`, else `session_id` — and ignores it outright otherwise. `ReviewGate` fills the
47
+ * device key in for exactly this reason, but this function posts whatever body it is handed, so a
48
+ * caller that assembles its own body without a unit is back in the old world where a lost detractor
49
+ * is a gap in the data and a duplicated one is a lie in the data.
45
50
  */
46
51
  declare const submitReview: (target: ReviewTarget | undefined, review: ReviewSubmission) => Promise<ReviewSubmitResult>;
47
52
  /**
@@ -1,4 +1,4 @@
1
- import { k as ReviewTarget, S as SubmitResult, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-h2BZvl1t.js';
1
+ import { k as ReviewTarget, S as SubmitResult, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-tdATL5z0.js';
2
2
 
3
3
  /**
4
4
  * What became of a review POST — the SHARED three-outcome verdict, re-exported under the name the
@@ -25,23 +25,28 @@ type ReviewSubmitResult = SubmitResult;
25
25
  *
26
26
  * It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
27
27
  * "nothing reached the server" and "the server answered non-2xx", and the one caller that reads
28
- * this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. But the server mints
29
- * its own row id (`create_review` / `_new_id()`) and `CreateReviewRequest` carries no id, so there
30
- * is NO idempotency key on the wire: a 502 returned AFTER the insert commits means the re-post
31
- * writes a SECOND row, double-counting `count` and corrupting `avg` the precise corruption the
32
- * one-row latch exists to prevent. A response of any status proves the server was reached, and
33
- * that is a different question from whether it liked the row. So the two are different values.
28
+ * this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. At the time there was
29
+ * no idempotency key on the wire at all, so a 502 returned AFTER the insert commits meant the
30
+ * re-post wrote a SECOND row, double-counting `count` and corrupting `avg` the precise corruption
31
+ * the one-row latch exists to prevent. A response of any status proves the server was reached, and
32
+ * that is a different question from whether it liked the row. So the two are different values, and
33
+ * they stay different: the split is what lets the caller decide, and the retry-on-`rejected`
34
+ * question is a recovery-budget decision the key does not answer on its own.
34
35
  *
35
36
  * ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
36
37
  *
37
38
  * `unsent` is not proof the server never got the row. A connection dropped after the request was
38
39
  * written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
39
- * an offline device. Retrying only on `unsent` is therefore SAFER, not SAFE: it removes the
40
- * double-post the server itself told us about, and leaves the narrow window where the answer never
41
- * made it back onto the wire. Closing that window needs a CLIENT-MINTED IDEMPOTENCY KEY the server
42
- * upserts on, which is a server change (`CreateReviewRequest` + `create_review`) and is not
43
- * something the kit can fake. Until it exists, prefer losing a row over inventing one: a lost
44
- * detractor is a gap in the data, a duplicated one is a lie in the data.
40
+ * an offline device. That used to make the `unsent` re-post SAFER rather than SAFE, and it is what
41
+ * the client-minted idempotency key now covers: `ReviewSubmission.idempotency_key` reaches
42
+ * `CreateReviewRequest`, and the server upserts the derived row rather than inserting a second one.
43
+ *
44
+ * IT IS A CAPABILITY, NOT A GUARANTEE, and the condition is not ours to enforce from in here. The
45
+ * server honours the key only when the post also carries a unit it can scope it to
46
+ * `meta.device_key`, else `session_id` — and ignores it outright otherwise. `ReviewGate` fills the
47
+ * device key in for exactly this reason, but this function posts whatever body it is handed, so a
48
+ * caller that assembles its own body without a unit is back in the old world where a lost detractor
49
+ * is a gap in the data and a duplicated one is a lie in the data.
45
50
  */
46
51
  declare const submitReview: (target: ReviewTarget | undefined, review: ReviewSubmission) => Promise<ReviewSubmitResult>;
47
52
  /**
@@ -1,5 +1,5 @@
1
1
  import { O as OnboardingTheme } from './types-BKfpdZzX.mjs';
2
- import { C as CoachmarkStorage } from './types-BcmagF6K.mjs';
2
+ import { C as CoachmarkStorage } from './types-D_0B0yay.mjs';
3
3
  import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.mjs';
4
4
 
5
5
  /**
@@ -25,20 +25,28 @@ import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.mjs
25
25
  *
26
26
  * `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
27
27
  * `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
28
- * treats `false` as "still owed" and re-posts. But the server mints its own row id
29
- * (`create_review` / `_new_id()`), and neither `CreateReviewRequest` nor the questionnaire response
30
- * body carries a client id, so there is NO idempotency key on the wire: a 502 returned AFTER the
31
- * insert commits means the re-post writes a SECOND row. A response of any status proves the server
32
- * was reached, and that is a different question from whether it liked the payload.
28
+ * treats `false` as "still owed" and re-posts. At the time neither body carried a client id, so
29
+ * there was NO idempotency key on the wire on either surface: a 502 returned AFTER the insert
30
+ * commits meant the re-post wrote a SECOND row. A response of any status proves the server was
31
+ * reached, and that is a different question from whether it liked the payload which is why the
32
+ * three values stay, on both surfaces, whatever the wire gains.
33
33
  *
34
34
  * ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
35
35
  *
36
36
  * `unsent` is not proof the server never got it. A connection dropped after the request was written
37
37
  * — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
38
- * device. Retrying only on `unsent` is therefore SAFER, not SAFE. Closing that window needs a
39
- * CLIENT-MINTED IDEMPOTENCY KEY the server upserts on, which is a server change and not something
40
- * the kit can fake. Until it exists, prefer losing a row over inventing one: a lost detractor is a
41
- * gap in the data, a duplicated one is a lie in the data.
38
+ * device. Closing that window needs a client-minted idempotency key the server upserts on, and AS
39
+ * OF 2026-08-19 THE TWO SURFACES DIFFER do not read this paragraph as one fact about both:
40
+ *
41
+ * REVIEWS closed. `CreateReviewRequest` takes an optional `idempotency_key` and the server
42
+ * upserts a derived row on it, so a `ReviewGate` re-post merges instead of duplicating. It is a
43
+ * capability, not a guarantee: the server honours the key only when the post also carries a
44
+ * unit to scope it to (`meta.device_key`, else `session_id`), so a caller that strips both is
45
+ * back to the old behaviour.
46
+ * • QUESTIONNAIRES — still open, and still exactly as this paragraph used to read for both. The
47
+ * response body carries no client id and the server has no idempotency path for it at all, so
48
+ * retrying only on `unsent` there is SAFER, not SAFE. Prefer losing a row over inventing one: a
49
+ * lost detractor is a gap in the data, a duplicated one is a lie in the data.
42
50
  */
43
51
  type SubmitResult = "accepted" | "rejected" | "unsent";
44
52
 
@@ -119,6 +127,13 @@ interface ReviewSubmission {
119
127
  contact?: string;
120
128
  session_id?: string;
121
129
  source?: string;
130
+ /**
131
+ * OPTIONAL client-minted key for the two-phase submit: two posts carrying the same key upsert ONE
132
+ * row instead of creating two. The server honours it ONLY when the post also carries a resolvable
133
+ * user — `meta.device_key`, else `session_id` — and silently ignores it otherwise, so it always
134
+ * travels with one of those. One key per gate impression, stable across that impression's posts.
135
+ */
136
+ idempotency_key?: string;
122
137
  meta?: Record<string, unknown>;
123
138
  }
124
139
  /**
@@ -18,8 +18,13 @@ interface TargetRect {
18
18
  }
19
19
  /**
20
20
  * The host-injected gate storage. SYNCHRONOUS on purpose — a coachmark's
21
- * "seen?" gate must resolve during render with no async flash (otherwise a ring
22
- * momentarily paints before the read resolves and says "already seen"). MMKV's
21
+ * "seen?" gate must resolve during render. This is ENFORCED, fail closed: a
22
+ * probe on first read catches an adapter whose `getItem` returns a thenable
23
+ * (AsyncStorage handed straight in) and substitutes a closed adapter — every
24
+ * gate reads "seen", nothing shows, nothing fires, one dev warning names the
25
+ * fix (see `validateGateStorage` in ./runtime). A raw async adapter would
26
+ * otherwise report every gate unseen forever: tours replay each open, and a
27
+ * review gate with `minSessions` 0/1 re-fires each open. MMKV's
23
28
  * `getString`/`set` fit as-is; an in-memory `Map` wrapper works for tests. This
24
29
  * is deliberately separate from the async `WireOnboardingStorage` used for
25
30
  * session persistence.
@@ -18,8 +18,13 @@ interface TargetRect {
18
18
  }
19
19
  /**
20
20
  * The host-injected gate storage. SYNCHRONOUS on purpose — a coachmark's
21
- * "seen?" gate must resolve during render with no async flash (otherwise a ring
22
- * momentarily paints before the read resolves and says "already seen"). MMKV's
21
+ * "seen?" gate must resolve during render. This is ENFORCED, fail closed: a
22
+ * probe on first read catches an adapter whose `getItem` returns a thenable
23
+ * (AsyncStorage handed straight in) and substitutes a closed adapter — every
24
+ * gate reads "seen", nothing shows, nothing fires, one dev warning names the
25
+ * fix (see `validateGateStorage` in ./runtime). A raw async adapter would
26
+ * otherwise report every gate unseen forever: tours replay each open, and a
27
+ * review gate with `minSessions` 0/1 re-fires each open. MMKV's
23
28
  * `getString`/`set` fit as-is; an in-memory `Map` wrapper works for tests. This
24
29
  * is deliberately separate from the async `WireOnboardingStorage` used for
25
30
  * session persistence.
@@ -1,5 +1,5 @@
1
1
  import { O as OnboardingTheme } from './types-BKfpdZzX.js';
2
- import { C as CoachmarkStorage } from './types-BcmagF6K.js';
2
+ import { C as CoachmarkStorage } from './types-D_0B0yay.js';
3
3
  import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.js';
4
4
 
5
5
  /**
@@ -25,20 +25,28 @@ import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.js'
25
25
  *
26
26
  * `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
27
27
  * `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
28
- * treats `false` as "still owed" and re-posts. But the server mints its own row id
29
- * (`create_review` / `_new_id()`), and neither `CreateReviewRequest` nor the questionnaire response
30
- * body carries a client id, so there is NO idempotency key on the wire: a 502 returned AFTER the
31
- * insert commits means the re-post writes a SECOND row. A response of any status proves the server
32
- * was reached, and that is a different question from whether it liked the payload.
28
+ * treats `false` as "still owed" and re-posts. At the time neither body carried a client id, so
29
+ * there was NO idempotency key on the wire on either surface: a 502 returned AFTER the insert
30
+ * commits meant the re-post wrote a SECOND row. A response of any status proves the server was
31
+ * reached, and that is a different question from whether it liked the payload which is why the
32
+ * three values stay, on both surfaces, whatever the wire gains.
33
33
  *
34
34
  * ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
35
35
  *
36
36
  * `unsent` is not proof the server never got it. A connection dropped after the request was written
37
37
  * — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
38
- * device. Retrying only on `unsent` is therefore SAFER, not SAFE. Closing that window needs a
39
- * CLIENT-MINTED IDEMPOTENCY KEY the server upserts on, which is a server change and not something
40
- * the kit can fake. Until it exists, prefer losing a row over inventing one: a lost detractor is a
41
- * gap in the data, a duplicated one is a lie in the data.
38
+ * device. Closing that window needs a client-minted idempotency key the server upserts on, and AS
39
+ * OF 2026-08-19 THE TWO SURFACES DIFFER do not read this paragraph as one fact about both:
40
+ *
41
+ * REVIEWS closed. `CreateReviewRequest` takes an optional `idempotency_key` and the server
42
+ * upserts a derived row on it, so a `ReviewGate` re-post merges instead of duplicating. It is a
43
+ * capability, not a guarantee: the server honours the key only when the post also carries a
44
+ * unit to scope it to (`meta.device_key`, else `session_id`), so a caller that strips both is
45
+ * back to the old behaviour.
46
+ * • QUESTIONNAIRES — still open, and still exactly as this paragraph used to read for both. The
47
+ * response body carries no client id and the server has no idempotency path for it at all, so
48
+ * retrying only on `unsent` there is SAFER, not SAFE. Prefer losing a row over inventing one: a
49
+ * lost detractor is a gap in the data, a duplicated one is a lie in the data.
42
50
  */
43
51
  type SubmitResult = "accepted" | "rejected" | "unsent";
44
52
 
@@ -119,6 +127,13 @@ interface ReviewSubmission {
119
127
  contact?: string;
120
128
  session_id?: string;
121
129
  source?: string;
130
+ /**
131
+ * OPTIONAL client-minted key for the two-phase submit: two posts carrying the same key upsert ONE
132
+ * row instead of creating two. The server honours it ONLY when the post also carries a resolvable
133
+ * user — `meta.device_key`, else `session_id` — and silently ignores it otherwise, so it always
134
+ * travels with one of those. One key per gate impression, stable across that impression's posts.
135
+ */
136
+ idempotency_key?: string;
122
137
  meta?: Record<string, unknown>;
123
138
  }
124
139
  /**
package/llms.txt CHANGED
@@ -8,19 +8,19 @@
8
8
 
9
9
  ## What an agent needs to know
10
10
 
11
- - **Install:** `npm install @wireai/activation wireai-rn` (react + react-native are peers).
11
+ - **Install:** `npm install @wireai/activation wireai-rn zod react-native-safe-area-context` (react + react-native are peers).
12
12
  - **Metro (required):** `module.exports = withWireOnboarding(getDefaultConfig(__dirname))` from `@wireai/activation/metro`, which pins one copy of react/react-native/wireai-rn/zod (prevents the dual-React crash).
13
- - **Two secrets:** an app `apiKey` (a `wai_…` key that resolves the tenant server-side) and the backend `serverUrl`. Created in the getwireai console or via the backend's `register_<app>.py`. Nothing renders without both.
14
- - **Render:** drop `<WireOnboarding config={wireConfigFromEnv({ appId })} theme={...} onComplete={persist} fallbackFlow={<YourStaticOnboarding/>} />` into the signup flow.
13
+ - **Two secrets:** an app `apiKey` (a `wai_…` key that resolves the tenant server-side) and the backend `serverUrl`. Both come from the console: sign up at https://getwireai.com/signup (app name + email; the confirmation link is where you choose a password, and that one step creates your first app and its key and lands you in the console already signed in), then read both off that app's Integrate tab at `https://getwireai.com/console/<app-id>/integrate`, where the key is a copy field and the server URL is the `serverUrl` line of the init snippet. The key stays readable there on every visit. Sign back in later at https://getwireai.com/login; register further apps at https://getwireai.com/console/register. Keys are per environment (dev / prod / staging). Nothing renders without both.
14
+ - **Render:** build the config once and gate on it (`wireConfigFromEnv` returns `null` when the env is unset, and `config` is a required non-null prop), then drop `<WireOnboarding config={config} theme={...} onComplete={persist} fallbackFlow={<YourStaticOnboarding/>} ... />` into the signup flow.
15
15
  - **The join key.** Pass `userContext={activationJoinContext(deviceKey)}` on `<WireOnboarding>`, or, when the app owns no device id, pass a working `storage` and leave `userContext` alone so the kit injects its own (⛔ never hand-build it from the synchronous `resolveAutoDeviceKey`, which cannot report whether the id survives the launch). `user_context.device_key` is the ONLY thing that joins an onboarding session to the app's later events; omit it and the `activated` funnel reads zero with no error.
16
- - **Lifecycle (mount it once):** `useLifecycleEvents(config, { deviceKey?, sessionCount?, userId? })` at the app root, before anything else touches analytics. It is the only path that emits `app.first_open`, it registers the per-open session id `createAnalytics` / `wire.track` / the gates all correlate to, and with `config.storage` it stamps the persisted auto `device_key` on `app.session_started`, which is what the server counts `min_sessions` from. Storage that throws or rejects counts as no storage since 0.14.0: the events fire with no auto key rather than a per-launch one. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`, and `useSessionStart` follows the same auto-key rule since 0.14.0.
16
+ - **Lifecycle (mount it once):** `useLifecycleEvents({ serverUrl, apiKey, appId, storage }, { deviceKey, sessionCount, userId })` at the app root, before anything else touches analytics. The first argument is a `LifecycleConfig` built from explicit fields, NOT the `WireOnboardingConfig` (`wireConfigFromEnv` can return `null`, and `storage` is not a config field). It is the only path that emits `app.first_open`, it registers the per-open session id `createAnalytics` / `wire.track` / the gates all correlate to, and with a `storage` adapter in that first argument it stamps the persisted auto `device_key` on `app.session_started`, which is what the server counts `min_sessions` from. Storage that throws or rejects counts as no storage since 0.14.0: the events fire with no auto key rather than a per-launch one. `sessionCount` in the second argument is the HOST's obligation: a counter the app persists and increments once per open, starting at 1. A hardcoded `1` compiles and pins every user at `returning: false` forever with no error anywhere. `useSessionStart` / `reportSessionStart` are the alternatives for a host that already owns an open counter; neither emits `first_open`, and `useSessionStart` follows the same auto-key rule since 0.14.0.
17
17
  - **Capture is deterministic.** `onComplete` returns an `OnboardingResult`: `answers` and `raw` always, plus (since 0.13.6) `plan` when the backend sent an onboarding plan and `variant` when the tenant runs an experiment and the backend assigned an arm. No second LLM call. Persist all answers through the app's normal profile-update path. Those two extra keys are set ONLY when the backend actually sent them, so a run without them returns exactly the object earlier versions returned, and the kit interprets neither: validate `plan` before applying it.
18
18
  - **It can never break onboarding.** Pass `fallbackFlow` (your existing static onboarding) so a backend error/timeout degrades instead of dead-ending.
19
19
 
20
20
  ## Helpers (the reusable substrate)
21
21
 
22
22
  - `wireConfigFromEnv({ appId, metadata? })` → config or null (reads `EXPO_PUBLIC_WIREAI_API_KEY`/`_SERVER_URL`/`_APP_ID`).
23
- - `isOnboardingEnabled({ remote? })` → the standard gate (transport present: apiKey + serverUrl). No on/off env flag; `remote` is an optional kill-switch the app passes in.
23
+ - `isOnboardingEnabled({ remote?, config? })` → the standard gate (transport present: apiKey + serverUrl). No on/off env flag; `remote` is an optional kill-switch the app passes in, and `config` forwards `WireConfigOverrides` (a programmatic apiKey/serverUrl) to the transport-present check.
24
24
  - `WIRE_ONBOARDING_EVENTS` + `toAnalyticsEvent(event)` → canonical `wire_onboarding_*` funnel names; log via the app's own analytics.
25
25
  - `attributionMetadata(a)` → shape install/ad attribution into `config.metadata` (forwarded to the agent).
26
26
  - `reportClientEvent(target, event)` / `reportClientEvents` / `makeSessionId` (ROOT-exported, not a subpath) → report device-only funnel events. Contract: `POST {serverUrl}/v1/events`, header `Authorization: Bearer {apiKey}`, body `{ "events": [ ... ] }`; `target = { serverUrl, apiKey }` from the config. `<WireOnboarding>` does this automatically: `dropped` on unmount-without-complete, `client_fallback` on degrade-to-static. Hosts must not double-report fallback.
@@ -30,10 +30,10 @@
30
30
 
31
31
  The package is more than `<WireOnboarding>`. Tree-shakeable subpath exports (each keeps the onboarding UI out of an analytics-only bundle):
32
32
 
33
- - `@wireai/activation/analytics`: `createAnalytics({ serverUrl, apiKey, storage })` (Segment/PostHog-shaped `track` / `screen` / `identify` / `setUserContext` / `reset` over an offline-first queue), `createScreenTracker` / `screenTrackingHandler` (auto screen views), `reportAppEvent`, `createEventQueue`, `clearUserContext` (logout). Rich `WireUserContext` supports an opt-in `userEmail` (raw by default, `hashEmail` to fold) and auto-mints a persisted per-install `device_key`. `identify` refuses email-shaped ids unless `allowEmailAsUserId`. See the README "Rich user context & PII" section. Also `wireDoctor({ target, storage })`, the DEV-ONLY self-check: four `{name, ok, detail}` checks (target shape, server reachability, storage writability, and a `dry_run` round trip whose verdict is `written === 1 && skipped === 0` read from the ack body, because `/v1/events` answers 200 for a batch it discards). It never throws, never prints any part of the apiKey, and does nothing at all when `__DEV__` is unset.
34
- - `@wireai/activation/reviews`: the in-app review gate. `useReviewGate`, `fetchReviewDecision` (server AI seam; a `{fire:false}` survives intact), `ReviewGate` UI. `minSessions` defaults to 2 (never prompts on the first session; a server decision still overrides).
33
+ - `@wireai/activation/analytics`: `createAnalytics({ serverUrl, apiKey, storage })` (Segment/PostHog-shaped `track` / `screen` / `identify` / `setUserContext` / `reset` over an offline-first queue), `createScreenTracker` / `screenTrackingHandler` (auto screen views), `reportAppEvent`, `createEventQueue`, `clearUserContext` (logout). Rich `WireUserContext` supports an opt-in `userEmail` (raw by default, `hashEmail` to fold) and auto-mints a persisted per-install `device_key`. `identify` refuses email-shaped ids unless `allowEmailAsUserId`. See the README "Rich user context & PII" section. Also `wireDoctor({ target, storage, join })`, the DEV-ONLY self-check: five `{name, ok, detail}` checks (target shape, server reachability, storage writability, a `dry_run` round trip whose verdict is `written === 1 && skipped === 0` read from the ack body because `/v1/events` answers 200 for a batch it discards, and `join_key`). It never throws, never prints any part of the apiKey or the device key, and does nothing at all when `__DEV__` is unset. `join_key` answers whether the session will carry a `user_context.device_key` (host-supplied or the kit's auto-injected one), and it needs the `join: { appId, userContext, autoJoinKey }` argument; omitting `join` FAILS that check rather than skipping it. It is strictly read-only: no mint, no identity-registry write, nothing persisted. Read the scope of a green report: it now says events reach the server AND can be joined. `wireDoctor` still does not check that the lifecycle hook is mounted and does not check `min_sessions`, so an integration missing either reports all-green while its `activated` funnel stays permanently at zero.
34
+ - `@wireai/activation/reviews`: the in-app review gate. `useReviewGate`, `fetchReviewDecision` (server AI seam; a `{fire:false}` survives intact), `ReviewGate` UI. `minSessions` defaults to 2 (never prompts on the first session; a server decision still overrides). THE GATE NEEDS SYNCHRONOUS STORAGE: pass `storage` to the hook, or mount `CoachmarkProvider` with one, as a `CoachmarkStorage` whose `getItem(key)` returns `string | null` and not a Promise. That is a DIFFERENT shape from `<WireOnboarding storage>`, which takes the AsyncStorage-style adapter. Without it the app-open counter is pinned at 1 forever, the fail-closed `minSessions` rule can never be met, and the gate silently never fires; an async adapter is detected and substituted with a fail-closed store, same outcome. The hook's `config` is the GATE's config (it needs an `id`), not the transport config, and `decision` takes `served ?? undefined` because the fetch resolves `ReviewDecisionResponse | null`. `<ReviewGate>` has no `gate` prop: render it on `gate.visible` and pass `onShown={gate.markShown}` + `onResolved={gate.markResolved}`, which are what persist the cooldown and the once-per-version seen gate. Omit them and it compiles, shows, and re-prompts forever. Full wiring: the "Wiring `<ReviewGate>`" block in AGENTS.md.
35
35
  - `@wireai/activation/questionnaire`: the pre-onboarding questionnaire gate. `useQuestionnaireGate`, `fetchQuestionnaireDecision` (same server seam; a body without a boolean `fire` resolves to null, never shows).
36
- - `createRevenueCatBridge({ analytics, entitlementId })` (ROOT-exported) → the RevenueCat purchase funnel: `paywallShown` / `checkoutStarted` / `purchaseCompleted` / `purchaseFailed` / `purchasesRestored` / `syncPlanTier`, emitting the canonical `wire_paywall_shown` / `wire_checkout_started` / `wire_purchase_completed` / `wire_purchase_failed` / `wire_purchase_restored` names. The kit does NOT depend on `react-native-purchases` (a native module); the RevenueCat objects are typed structurally, so a host passes the real ones. THE JOIN KEY between a purchase and an onboarding is `user_context.device_key`, never `session_id` (an onboarding session id is the A2A contextId, an app-event session id is the per-open id, and they are separate spaces): pass the same device key to `createAnalytics`/`createWireActivation` AND to `<WireOnboarding userContext={activationJoinContext(deviceKey)} />`.
36
+ - `createRevenueCatBridge({ analytics, entitlementId })` (ROOT-exported) → the RevenueCat purchase funnel: `paywallShown` / `checkoutStarted` / `purchaseCompleted` / `purchaseFailed` / `purchasesRestored` / `syncPlanTier`, emitting the canonical `wire_paywall_shown` / `wire_checkout_started` / `wire_purchase_completed` / `wire_purchase_failed` / `wire_purchase_restored` names. The kit does NOT depend on `react-native-purchases` (a native module); the RevenueCat objects are typed structurally, so a host passes the real ones. THE JOIN KEY between a purchase and an onboarding is `user_context.device_key`, never `session_id` (an onboarding session id is the A2A contextId, an app-event session id is the per-open id, and they are separate spaces): pass the same device key to `createAnalytics`/`createWireActivation` AND to `<WireOnboarding userContext={activationJoinContext(deviceKey)} ... />`.
37
37
  - `useWireActivation({ serverUrl, apiKey, deviceKey? })` → `{ track, sessionId, revalidation }` (ROOT-exported; React-free factory `createWireActivation(config)`). `await track(name, meta?)` POSTs an `app_event` (`question_key=name`) under the current session, resolves `true` on 2xx, and bumps `revalidation`; list `revalidation` in a `fetchReviewDecision` / `fetchQuestionnaireDecision` effect's deps so a review/questionnaire gate re-fetches and fires off an in-app action instead of the host hand-rolling session-id + await-POST + revalidate.
38
38
 
39
39
  ## Files
@@ -45,4 +45,4 @@ The package is more than `<WireOnboarding>`. Tree-shakeable subpath exports (eac
45
45
 
46
46
  ## Fastest path
47
47
 
48
- Run the `wire-rn-integration` Claude skill. It detects the app's conventions, wires Metro, derives a theme, scaffolds the screen, wires a fallback, and type-checks. Otherwise use INTEGRATION_PROMPT.md.
48
+ Paste [INTEGRATION_PROMPT.md](./INTEGRATION_PROMPT.md) into a coding agent. It ships in this package and drives the whole integration: detect the app's conventions, wire Metro, derive a theme, scaffold the screen, wire a fallback, and type-check.
package/package.json CHANGED
@@ -1,17 +1,14 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.14.3",
3
+ "version": "0.16.0",
4
4
  "private": false,
5
5
  "description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
6
6
  "author": "Malik Chohra <malik@getwireai.com>",
7
7
  "license": "MIT",
8
- "homepage": "https://github.com/chohra-med/wireai-onboarding#readme",
9
- "repository": {
10
- "type": "git",
11
- "url": "git+https://github.com/chohra-med/wireai-onboarding.git"
12
- },
8
+ "homepage": "https://getwireai.com",
13
9
  "bugs": {
14
- "url": "https://github.com/chohra-med/wireai-onboarding/issues"
10
+ "url": "https://getwireai.com/console/support",
11
+ "email": "malik@getwireai.com"
15
12
  },
16
13
  "keywords": [
17
14
  "react-native",
@@ -137,12 +134,12 @@
137
134
  "scripts": {
138
135
  "build": "tsup",
139
136
  "prepack": "npm run build",
140
- "prepublishOnly": "npm run build && npm run test:canary",
137
+ "prepublishOnly": "npm run build && npm test",
141
138
  "typecheck": "tsc --noEmit",
142
139
  "pretest": "npm run build",
143
140
  "test": "node --test --experimental-strip-types --import ./test/register-ts-resolver.mjs \"src/**/*.test.ts\" \"test/canary/**/*.test.tsx\"",
144
141
  "test:canary": "node --test --experimental-strip-types --import ./test/register-ts-resolver.mjs \"test/canary/**/*.test.tsx\"",
145
- "check:contract": "node --no-warnings --experimental-strip-types scripts/check-events-contract.mjs",
142
+ "check:contract": "node --no-warnings --experimental-strip-types --import ./test/register-ts-resolver.mjs scripts/check-events-contract.mjs",
146
143
  "pack:tarball": "npm pack"
147
144
  },
148
145
  "peerDependencies": {
@@ -135,7 +135,10 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
135
135
  // per-install `device_key` the analytics surfaces auto-mint and persist — so the default wiring
136
136
  // produces a joined funnel instead of a silent zero. Three conditions, all necessary:
137
137
  //
138
- // • `missingJoinKey` — a host-supplied key ALWAYS wins, verbatim, and is never touched.
138
+ // • `missingJoinKey` — a host-supplied key ALWAYS wins, verbatim, and is never touched. What
139
+ // COUNTS as supplied is `resolveIdentity`'s predicate above: a string with at least one
140
+ // non-whitespace character. A non-string or an all-whitespace `device_key` yields no record,
141
+ // so it reads as absent here and the auto key is merged over it.
139
142
  // • `autoJoinKey !== false` — the documented opt-out for a host that genuinely wants an
140
143
  // unlinked onboarding session; opting out restores the pre-0.12.2 behavior exactly.
141
144
  // • `storage` that actually WORKS — without persistence the auto id is process-scoped, so every
@@ -77,13 +77,93 @@ type GlobalWithSlot = typeof globalThis & {
77
77
 
78
78
  const globalSlot = globalThis as GlobalWithSlot;
79
79
 
80
+ /**
81
+ * Subscribers to be told when the id above CHANGES.
82
+ *
83
+ * ── WHY A REGISTRY THAT ONLY EMITS IS NOT ENOUGH ─────────────────────────────────────────────────
84
+ * The gates' app-open counter is bumped from a `useState` INITIALIZER, i.e. exactly once per
85
+ * component instance. `bumpSessionCount` itself follows real app-opens correctly, but nothing ever
86
+ * calls it a second time on a screen that stays mounted — and iOS suspends rather than kills, so a
87
+ * home screen holding `useReviewGate` outlives many opens. `useLifecycleEvents` registers each one
88
+ * here and the SERVER's `min_sessions` advances, while `wire_review_<id>_sessions` sits at its launch
89
+ * value and the LOCAL `minSessions` rule is unsatisfiable for the life of the process. Silent, and
90
+ * fails toward a prompt that never shows.
91
+ *
92
+ * The fix is not a second counter: it is that this module — the one place that knows an open was
93
+ * registered — says so. The gates subscribe (through `useSyncExternalStore`) and re-ask
94
+ * `bumpSessionCount`, which was already idempotent per open, so a notification that names the SAME
95
+ * open changes nothing.
96
+ *
97
+ * EVERY write to the id slot notifies, including `ensureCurrentSessionId`'s mint and the test-only
98
+ * reset: `getCurrentSessionId` is the `useSyncExternalStore` snapshot, and a snapshot that can change
99
+ * without a notification is a torn read, not an optimisation.
100
+ *
101
+ * @globalSlot LATCH — the listener SET is created once and its identity is then stable, for the same
102
+ * reason `activation/revalidation`'s store is: a second write replaces the set every existing
103
+ * subscriber registered in, so their unsubscribes free the wrong set and a registration reaches
104
+ * nobody — silently, which is the whole failure class this seam exists to close. Its CONTENTS are
105
+ * live and mutated in place, which is why every accessor goes through `listeners()`.
106
+ */
107
+ const SESSION_ID_LISTENERS_SLOT: unique symbol = Symbol.for(
108
+ "@wireai/activation:currentSessionIdListeners",
109
+ );
110
+
111
+ type GlobalWithListeners = typeof globalThis & {
112
+ [SESSION_ID_LISTENERS_SLOT]?: Set<() => void>;
113
+ };
114
+
115
+ const listenerSlot = globalThis as GlobalWithListeners;
116
+
117
+ /** The ONE shared listener set, lazily created on the runtime global (never a second copy per bundle). */
118
+ const listeners = (): Set<() => void> => {
119
+ const existing = listenerSlot[SESSION_ID_LISTENERS_SLOT];
120
+ if (existing) return existing;
121
+ const created = new Set<() => void>();
122
+ listenerSlot[SESSION_ID_LISTENERS_SLOT] = created;
123
+ return created;
124
+ };
125
+
126
+ /**
127
+ * Write the slot and, only on a REAL change, tell every subscriber. The single writer for all three
128
+ * public entry points, so "the snapshot moved" and "the subscribers were told" can never drift.
129
+ */
130
+ const writeCurrentSessionId = (id: string | undefined): void => {
131
+ if (globalSlot[CURRENT_SESSION_ID_SLOT] === id) return;
132
+ globalSlot[CURRENT_SESSION_ID_SLOT] = id;
133
+ // Iterate a snapshot so a listener that (un)subscribes during notify never corrupts the walk, and
134
+ // isolate each one: a throwing subscriber must not stop the others (fail-safe).
135
+ for (const listener of Array.from(listeners())) {
136
+ try {
137
+ listener();
138
+ } catch {
139
+ /* a subscriber's failure is not this registry's business */
140
+ }
141
+ }
142
+ };
143
+
144
+ /**
145
+ * Subscribe to app-open registrations. Returns an unsubscribe fn. Shaped for `useSyncExternalStore`
146
+ * over {@link getCurrentSessionId} — that pairing is what makes a mounted gate follow real opens
147
+ * instead of the one it sampled at mount.
148
+ *
149
+ * Internal seam — deliberately NOT on the public barrels (same as {@link isMintedSessionId}); the
150
+ * callers are `useReviewGate` / `useQuestionnaireGate`.
151
+ */
152
+ export const subscribeCurrentSessionId = (listener: () => void): (() => void) => {
153
+ const set = listeners();
154
+ set.add(listener);
155
+ return () => {
156
+ set.delete(listener);
157
+ };
158
+ };
159
+
80
160
  /**
81
161
  * Record the current per-open `session_id`. Called by `reportSessionStart` when it emits an
82
162
  * app-open. A blank / non-string id is ignored (the previous id stays current). Idempotent.
83
163
  */
84
164
  export const setCurrentSessionId = (id: string | undefined): void => {
85
165
  if (typeof id === "string" && id.length > 0) {
86
- globalSlot[CURRENT_SESSION_ID_SLOT] = id;
166
+ writeCurrentSessionId(id);
87
167
  }
88
168
  };
89
169
 
@@ -91,9 +171,17 @@ export const setCurrentSessionId = (id: string | undefined): void => {
91
171
  export const getCurrentSessionId = (): string | undefined =>
92
172
  globalSlot[CURRENT_SESSION_ID_SLOT];
93
173
 
94
- /** Test-only: forget the current session id so a unit test starts from a clean registry. */
174
+ /** Test-only: forget the current session id so a unit test starts from a clean registry. Does NOT
175
+ * clear the MINTED-id record (see `isMintedSessionId`), for the same reason the warn latch below is
176
+ * not cleared: `makeSessionId()` never repeats, so a remembered fallback id can never again equal a
177
+ * live one, and the record is overwritten by the next mint anyway. */
95
178
  export const resetCurrentSessionId = (): void => {
96
- globalSlot[CURRENT_SESSION_ID_SLOT] = undefined;
179
+ writeCurrentSessionId(undefined);
180
+ };
181
+
182
+ /** Test-only: drop every subscriber so one case's mounted gate cannot be notified by the next. */
183
+ export const resetCurrentSessionIdListeners = (): void => {
184
+ listeners().clear();
97
185
  };
98
186
 
99
187
  /** The one-time message. Hoisted so a prod mint does not rebuild a string nobody will read. */
@@ -120,6 +208,52 @@ type GlobalWithWarnSlot = typeof globalThis & { [MINT_WARNED_SLOT]?: boolean };
120
208
 
121
209
  const warnSlot = globalThis as GlobalWithWarnSlot;
122
210
 
211
+ /**
212
+ * The id THIS PROCESS minted, so a reader can tell a fallback id from a real app-open.
213
+ *
214
+ * WHY IT HAS TO EXIST. A mint means "no app-open was registered and something still needed a
215
+ * `session_id`". It is a fallback for the wire, NOT evidence that the app was opened. But the mint
216
+ * writes the shared slot above (it must — see the header: every later event has to correlate to the
217
+ * same id), so a reader of `getCurrentSessionId()` cannot tell the two apart. `reviews/runtime`'s
218
+ * `currentOpenId` could not, and that cost an app-open: it pinned the launch with
219
+ * `observed = <the minted id>`, so its cold-start adopt branch (which keys off `observed ===
220
+ * undefined`) never fired and the REAL `app.session_started` that followed read as a genuinely NEW
221
+ * open. One launch counted as TWO, making the fail-closed `minSessions: 2` default — the guard from
222
+ * the 2026-07-16 one-star incident — satisfiable inside a user's FIRST session. It failed in the
223
+ * UNSAFE direction.
224
+ *
225
+ * Its OWN `Symbol.for` slot, for the same cross-bundle reason as the id itself: a plain module
226
+ * `let` would be per inlined bundle copy, so a mint reached through `./analytics` would be invisible
227
+ * to the gate reading through `.`.
228
+ *
229
+ * @globalSlot LIVE — a real process mints at most once (the mint only fires when the id slot is
230
+ * EMPTY), but a process that clears the id and mints again — `resetCurrentSessionId` in the suite —
231
+ * MOVES this, and it must move: a record still naming the previous launch's fallback would make the
232
+ * new one unrecognisable, and a fallback id would be counted as an app-open again. So a reader that
233
+ * caches this answers
234
+ * for a launch that is over. Read it at the moment of use, through `isMintedSessionId()`.
235
+ */
236
+ const MINTED_SESSION_ID_SLOT: unique symbol = Symbol.for(
237
+ "@wireai/activation:mintedSessionId",
238
+ );
239
+
240
+ type GlobalWithMintedSlot = typeof globalThis & {
241
+ [MINTED_SESSION_ID_SLOT]?: string | undefined;
242
+ };
243
+
244
+ const mintedSlot = globalThis as GlobalWithMintedSlot;
245
+
246
+ /**
247
+ * Was `id` MINTED by this process rather than registered as a real app-open?
248
+ *
249
+ * `false` for `undefined`, for any id a host registered through `setCurrentSessionId`, and for
250
+ * every id once the launch has moved on. Internal seam — deliberately NOT on the public barrels;
251
+ * the only caller is `reviews/runtime`'s `currentOpenId`, which must not count a fallback id as an
252
+ * app-open.
253
+ */
254
+ export const isMintedSessionId = (id: string | undefined): boolean =>
255
+ id !== undefined && id === mintedSlot[MINTED_SESSION_ID_SLOT];
256
+
123
257
  /**
124
258
  * The current per-open `session_id`, MINTING and registering one when no app-open has been
125
259
  * registered yet. Always returns a non-empty string. Idempotent (a second call returns the same id)
@@ -141,7 +275,10 @@ export const ensureCurrentSessionId = (): string => {
141
275
  const existing = globalSlot[CURRENT_SESSION_ID_SLOT];
142
276
  if (typeof existing === "string" && existing.length > 0) return existing;
143
277
  const minted = makeSessionId();
144
- globalSlot[CURRENT_SESSION_ID_SLOT] = minted;
278
+ writeCurrentSessionId(minted);
279
+ // Remember that WE minted it. A fallback id is not an app-open, and `reviews/runtime` has to be
280
+ // able to tell the difference or the real registration that follows counts as a second open.
281
+ mintedSlot[MINTED_SESSION_ID_SLOT] = minted;
145
282
  if (!warnSlot[MINT_WARNED_SLOT] && warnInDev(MINT_WARNING)) {
146
283
  warnSlot[MINT_WARNED_SLOT] = true;
147
284
  }
@@ -44,7 +44,12 @@ export type { ClientEvent, ClientEventType, ClientEventTarget } from "./reportCl
44
44
  // `readEventsAck` is deliberately NOT re-exported here: the doctor imports it from the transport
45
45
  // module directly, so reuse costs nothing and the package's public surface stays as it was.
46
46
  export { wireDoctor } from "./wireDoctor";
47
- export type { WireDoctorCheck, WireDoctorReport, WireDoctorOptions } from "./wireDoctor";
47
+ export type {
48
+ WireDoctorCheck,
49
+ WireDoctorReport,
50
+ WireDoctorOptions,
51
+ WireDoctorJoinTarget,
52
+ } from "./wireDoctor";
48
53
 
49
54
  // ─── Canonical onboarding funnel names + kit-event mapper ──────────────────────
50
55
  export { WIRE_ONBOARDING_EVENTS, toAnalyticsEvent } from "./analyticsEvent";