@wireai/activation 0.14.3 → 0.15.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 (59) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +73 -0
  3. package/README.md +11 -7
  4. package/dist/analytics/index.d.mts +5 -5
  5. package/dist/analytics/index.d.ts +5 -5
  6. package/dist/analytics/index.js.map +1 -1
  7. package/dist/analytics/index.mjs.map +1 -1
  8. package/dist/coachmarks/index.d.mts +1 -1
  9. package/dist/coachmarks/index.d.ts +1 -1
  10. package/dist/coachmarks/index.js +47 -2
  11. package/dist/coachmarks/index.js.map +1 -1
  12. package/dist/coachmarks/index.mjs +47 -2
  13. package/dist/coachmarks/index.mjs.map +1 -1
  14. package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-CFSRZ2wg.d.ts} +10 -4
  15. package/dist/{currentSession-CUvTOchb.d.mts → currentSession-orZy5p1e.d.mts} +10 -4
  16. package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
  17. package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
  18. package/dist/index.d.mts +2 -2
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/questionnaire/index.d.mts +3 -3
  23. package/dist/questionnaire/index.d.ts +3 -3
  24. package/dist/questionnaire/index.js +48 -3
  25. package/dist/questionnaire/index.js.map +1 -1
  26. package/dist/questionnaire/index.mjs +48 -3
  27. package/dist/questionnaire/index.mjs.map +1 -1
  28. package/dist/reviews/index.d.mts +10 -6
  29. package/dist/reviews/index.d.ts +10 -6
  30. package/dist/reviews/index.js +167 -18
  31. package/dist/reviews/index.js.map +1 -1
  32. package/dist/reviews/index.mjs +167 -18
  33. package/dist/reviews/index.mjs.map +1 -1
  34. package/dist/showcase/index.d.mts +1 -1
  35. package/dist/showcase/index.d.ts +1 -1
  36. package/dist/showcase/index.js +47 -2
  37. package/dist/showcase/index.js.map +1 -1
  38. package/dist/showcase/index.mjs +47 -2
  39. package/dist/showcase/index.mjs.map +1 -1
  40. package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
  41. package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
  42. package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
  43. package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
  44. package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
  45. package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
  46. package/package.json +1 -1
  47. package/src/WireOnboarding.tsx +4 -1
  48. package/src/coachmarks/runtime.ts +103 -2
  49. package/src/coachmarks/types.ts +7 -2
  50. package/src/context/deviceId.ts +32 -0
  51. package/src/reviews/ReviewGate.tsx +86 -37
  52. package/src/reviews/decision.ts +11 -1
  53. package/src/reviews/idempotency.ts +71 -0
  54. package/src/reviews/index.ts +4 -2
  55. package/src/reviews/runtime.ts +5 -3
  56. package/src/reviews/transport.ts +17 -12
  57. package/src/reviews/types.ts +7 -0
  58. package/src/types.ts +10 -4
  59. package/src/utils/submitResult.ts +17 -9
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wireai/activation",
3
- "version": "0.14.3",
3
+ "version": "0.15.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>",
@@ -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
@@ -27,6 +27,9 @@
27
27
  */
28
28
  import type { CoachmarkStorage } from "./types";
29
29
 
30
+ /** RN sets this global; absent under node/SSR. Read defensively via the async-adapter warn below. */
31
+ declare const __DEV__: boolean | undefined;
32
+
30
33
  /**
31
34
  * Well-known key into the runtime-global symbol registry — one coachmark runtime across bundles.
32
35
  *
@@ -166,6 +169,104 @@ export const showcaseGateKey = (showcaseId: string): string =>
166
169
  /** Marker value written for a seen gate. */
167
170
  const SEEN_VALUE = "1";
168
171
 
172
+ /** Key used only by the one-off synchronicity probe below. Never carries a meaningful value. */
173
+ const GATE_PROBE_KEY = "wire_gate_probe";
174
+
175
+ /** Once-per-process latch on its own `Symbol.for` slot, mirroring `gateStorageWarned` next door
176
+ * in reviews/runtime: a plain `let` would warn once per inlined bundle copy, and the gates live
177
+ * in three of them.
178
+ *
179
+ * @globalSlot LATCH — written `true` by the first probe that caught an ASYNC gate adapter, and
180
+ * never again. A second differing write re-arms a warning the process already spent, so a host
181
+ * whose broken adapter reaches three gates gets the same paragraph three times and stops reading. */
182
+ const ASYNC_GATE_STORAGE_WARNED_SLOT: unique symbol = Symbol.for(
183
+ "@wireai/activation:asyncGateStorageWarned",
184
+ );
185
+
186
+ type GlobalWithAsyncGateWarn = typeof globalThis & {
187
+ [ASYNC_GATE_STORAGE_WARNED_SLOT]?: boolean;
188
+ };
189
+
190
+ const asyncGateWarnGlobal = globalThis as GlobalWithAsyncGateWarn;
191
+
192
+ /**
193
+ * The substitute for an adapter whose `getItem` returned a thenable (an ASYNC adapter, e.g.
194
+ * AsyncStorage handed straight in). Every seen-gate reads SEEN and every write is dropped, so
195
+ * nothing shows and nothing fires — FAIL CLOSED, deliberately in both directions the async bug
196
+ * could err: a Promise is never `=== "1"`, so a raw async adapter reports every gate UNSEEN
197
+ * forever — tours and showcases replay on every open, and a review gate with `minSessions` 0/1
198
+ * RE-FIRES its prompt each open (the 2026-07-16 one-star incident class). Never-fire is the only
199
+ * direction a once-per-user gate may err in.
200
+ */
201
+ const closedGateStorage: CoachmarkStorage = Object.freeze({
202
+ getItem: (): string => SEEN_VALUE,
203
+ setItem: (): void => {},
204
+ });
205
+
206
+ /** Adapters probed sync (or throwing, which every reader already tolerates): pass through. */
207
+ const syncGateAdapters = new WeakSet<CoachmarkStorage>();
208
+ /** Adapters caught returning a thenable: substituted, and never probed again. */
209
+ const asyncGateAdapters = new WeakSet<CoachmarkStorage>();
210
+
211
+ /** Warn once, in dev builds only, when a gate adapter turned out to be async. */
212
+ const warnAsyncGateStorage = (): void => {
213
+ if (asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT]) return;
214
+ if (typeof __DEV__ === "undefined" || !__DEV__) return;
215
+ if (typeof console === "undefined" || !console.warn) return;
216
+ asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT] = true;
217
+ console.warn(
218
+ "[wireai] the gate storage's getItem returned a Promise — an async adapter (e.g. AsyncStorage) " +
219
+ "cannot drive the once-per-user gates, so coachmark tours, showcases and the review/questionnaire " +
220
+ "gates are all DISABLED (fail closed: nothing shows, nothing fires) until a synchronous adapter " +
221
+ "is provided. Pass a sync adapter (e.g. MMKV: `{ getItem: (k) => mmkv.getString(k) ?? null, " +
222
+ "setItem: (k, v) => mmkv.set(k, v) }`) to CoachmarkProvider or the gate's `storage` prop.",
223
+ );
224
+ };
225
+
226
+ /** Test-only: forget the once-per-process async-adapter warn latch. */
227
+ export const resetAsyncGateStorageWarning = (): void => {
228
+ asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT] = undefined;
229
+ };
230
+
231
+ /**
232
+ * The thenable probe (fail closed): resolve an adapter to itself when its `getItem` is
233
+ * synchronous, and to `closedGateStorage` when it returns a thenable. Called on every gate READ
234
+ * rather than at injection, so (a) a host handing storage straight to a gate's `storage` prop is
235
+ * probed too, and (b) `CoachmarkProvider`'s unmount cleanup — which compares the singleton to its
236
+ * own prop by IDENTITY — keeps seeing the object the host passed. Verdicts are WeakSet-cached, so
237
+ * each adapter pays for exactly one probe read.
238
+ */
239
+ export const validateGateStorage = (
240
+ storage: CoachmarkStorage | null,
241
+ ): CoachmarkStorage | null => {
242
+ if (!storage) return null;
243
+ if (syncGateAdapters.has(storage)) return storage;
244
+ if (asyncGateAdapters.has(storage)) return closedGateStorage;
245
+ let probed: unknown;
246
+ try {
247
+ probed = storage.getItem(GATE_PROBE_KEY);
248
+ } catch {
249
+ // A throwing adapter is synchronous, which is all the probe rules on; every reader already
250
+ // tolerates the throw itself.
251
+ syncGateAdapters.add(storage);
252
+ return storage;
253
+ }
254
+ if (probed != null && typeof (probed as { then?: unknown }).then === "function") {
255
+ asyncGateAdapters.add(storage);
256
+ try {
257
+ // Detach the probe's promise so an adapter that REJECTS (e.g. a broken native store) cannot
258
+ // surface as an unhandled rejection the host never scheduled.
259
+ (probed as PromiseLike<unknown>).then(undefined, () => {});
260
+ } catch {
261
+ // A hostile thenable whose `then` throws is still async; the verdict stands.
262
+ }
263
+ warnAsyncGateStorage();
264
+ return closedGateStorage;
265
+ }
266
+ syncGateAdapters.add(storage);
267
+ return storage;
268
+ };
269
+
169
270
  /**
170
271
  * Read a seen gate. Testing mode ALWAYS reports unseen (so everything replays).
171
272
  * A missing storage (no provider) also reports unseen — the safe default is to
@@ -179,7 +280,7 @@ export const hasSeenGate = (
179
280
  testingOverride?: boolean,
180
281
  ): boolean => {
181
282
  if (testingOverride ?? isCoachmarkTesting()) return false;
182
- const storage = storageOverride ?? getCoachmarkStorage();
283
+ const storage = validateGateStorage(storageOverride ?? getCoachmarkStorage());
183
284
  if (!storage) return false;
184
285
  try {
185
286
  return storage.getItem(key) === SEEN_VALUE;
@@ -198,7 +299,7 @@ export const markSeenGate = (
198
299
  testingOverride?: boolean,
199
300
  ): void => {
200
301
  if (testingOverride ?? isCoachmarkTesting()) return;
201
- const storage = storageOverride ?? getCoachmarkStorage();
302
+ const storage = validateGateStorage(storageOverride ?? getCoachmarkStorage());
202
303
  if (!storage) return;
203
304
  try {
204
305
  storage.setItem(key, SEEN_VALUE);
@@ -31,8 +31,13 @@ export interface TargetRect {
31
31
 
32
32
  /**
33
33
  * The host-injected gate storage. SYNCHRONOUS on purpose — a coachmark's
34
- * "seen?" gate must resolve during render with no async flash (otherwise a ring
35
- * momentarily paints before the read resolves and says "already seen"). MMKV's
34
+ * "seen?" gate must resolve during render. This is ENFORCED, fail closed: a
35
+ * probe on first read catches an adapter whose `getItem` returns a thenable
36
+ * (AsyncStorage handed straight in) and substitutes a closed adapter — every
37
+ * gate reads "seen", nothing shows, nothing fires, one dev warning names the
38
+ * fix (see `validateGateStorage` in ./runtime). A raw async adapter would
39
+ * otherwise report every gate unseen forever: tours replay each open, and a
40
+ * review gate with `minSessions` 0/1 re-fires each open. MMKV's
36
41
  * `getString`/`set` fit as-is; an in-memory `Map` wrapper works for tests. This
37
42
  * is deliberately separate from the async `WireOnboardingStorage` used for
38
43
  * session persistence.
@@ -291,3 +291,35 @@ export const resetAutoDeviceKeys = (): void => {
291
291
  registry.hydrating.clear();
292
292
  registry.pending?.clear();
293
293
  };
294
+
295
+ /**
296
+ * The auto `device_key` a surface with NO `appId` and NO `storage` of its own may stamp onto a
297
+ * payload — the review gate being the one that exists (`ReviewGate` takes neither prop).
298
+ *
299
+ * WHY IT IS NOT JUST `resolveAutoDeviceKey()`. That function is keyed by `appId`, and `appId` is a
300
+ * REQUIRED field on `WireOnboardingConfig` that real hosts set to a real value. A propless caller
301
+ * can only pass `undefined`, which addresses the `"default"` slot — so on any host that names its
302
+ * app, a bare call would MINT a second id beside the one every other surface already shares. That is
303
+ * the halved-counter defect this registry exists to close (see the registry note above), arrived at
304
+ * from the other direction.
305
+ *
306
+ * So this READS and does not address:
307
+ * • exactly one id space in the process → that id, whoever registered it. This is every
308
+ * single-tenant app, i.e. every real app, and it is how the gate joins the analytics surfaces.
309
+ * • none at all → mint one through {@link resolveAutoDeviceKey}. Nothing exists to collide with,
310
+ * so `"default"` is not a second space, it is the first. PROCESS-scoped (there is no storage to
311
+ * persist through), which is honest and still resolvable: a review row is written once per user,
312
+ * so a per-launch value here cannot corrupt a counter the way it corrupts `min_sessions`.
313
+ * • two or more → `undefined`. Two tenants in one process must never share an id, and a caller
314
+ * with no `appId` cannot say which one it belongs to. Refusing is the only safe answer;
315
+ * guessing would stamp one tenant's device onto the other's row.
316
+ *
317
+ * Never throws, never mints a SECOND id, and never overrides anything a host supplied — the caller
318
+ * checks for a host value first.
319
+ */
320
+ export const ambientAutoDeviceKey = (): string | undefined => {
321
+ const registry = autoDeviceKeyRegistry();
322
+ if (registry.keys.size === 0) return resolveAutoDeviceKey();
323
+ if (registry.keys.size > 1) return undefined;
324
+ return [...registry.keys.values()][0];
325
+ };