@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
@@ -0,0 +1,109 @@
1
+ /**
2
+ * idempotency.ts — the ONE idempotency key and the ONE identity a single gate impression posts under.
3
+ *
4
+ * ── WHAT THE SERVER DOES WITH THESE, AND WHY BOTH ARE REQUIRED ───────────────────────────────
5
+ *
6
+ * `POST /v1/reviews` accepts an optional `idempotency_key`: two posts carrying the same key upsert
7
+ * ONE row instead of creating two, which is what stops a re-post double-counting `count` and
8
+ * corrupting `avg`. The row id is derived, never the key verbatim —
9
+ * `rev_ + sha256(app_id : unit : key)[:24]` — where `app_id` comes from the resolved `wai_` Bearer
10
+ * key (never the body) and `unit` is the USER the key is scoped to.
11
+ *
12
+ * THE PART THAT MAKES A KEY-ONLY CHANGE INERT: `unit` is `meta.device_key`, else `session_id`, and
13
+ * when the post carries NEITHER the server drops the key on the floor and inserts a normal row —
14
+ * deliberately, because an unbounded key could merge two strangers' reviews. The gate lives on the
15
+ * home feed, where a user legitimately has no onboarding session, so `session_id` is frequently
16
+ * absent; and nothing in the kit ever put a `device_key` into a review `meta` — that was documented
17
+ * as a host responsibility. Sending a key without guaranteeing the identity would therefore have
18
+ * shipped a field the server discards.
19
+ *
20
+ * ── THE SCOPE IS THE WHOLE DESIGN ────────────────────────────────────────────────────────────
21
+ *
22
+ * One key per gate IMPRESSION, held across every post of that impression:
23
+ * • minted per POST instead → the re-post carries a different key, writes the second row, and
24
+ * defeats the feature on the exact path it exists to protect.
25
+ * • minted once per INSTALL instead → a later, genuine second review merges into the first and
26
+ * destroys a rating.
27
+ * The identity is pinned for the same reason and in the same object: the auto device key is adopted
28
+ * from persistence ASYNCHRONOUSLY, so a value re-read between two posts of one impression can move
29
+ * under us, and two posts under two different units hash to two different rows.
30
+ */
31
+ import { ambientAutoDeviceKey } from "../context/deviceId";
32
+
33
+ /** Prefix so a kit-minted review key is visibly ours in a stored `meta` dump. */
34
+ export const REVIEW_IDEMPOTENCY_PREFIX = "rvw_";
35
+
36
+ /**
37
+ * Mint a fresh idempotency key. Dependency-free timestamp + randomness, the same scheme
38
+ * `makeSessionId` and `mintDeviceId` already use — the key only has to be unique among the
39
+ * impressions of ONE install, because the server hashes it together with `app_id` and the unit
40
+ * before it ever becomes a row id.
41
+ */
42
+ export const mintIdempotencyKey = (): string =>
43
+ `${REVIEW_IDEMPOTENCY_PREFIX}${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
44
+
45
+ /** Whether a `meta` bucket already carries a usable, host-supplied `device_key`. */
46
+ export const hasDeviceKey = (meta: Record<string, unknown> | undefined): boolean => {
47
+ const value = meta?.device_key;
48
+ return typeof value === "string" && value.trim().length > 0;
49
+ };
50
+
51
+ /** The key + identity every post of ONE gate impression carries. Both resolved once, at creation. */
52
+ export interface ReviewImpression {
53
+ /** The `idempotency_key` on the wire. Stable for the impression, distinct across impressions. */
54
+ readonly idempotencyKey: string;
55
+ /**
56
+ * The auto `device_key` to stamp when the host supplied none, or `undefined` when the kit cannot
57
+ * name one honestly (two tenants in one process). A host-supplied key that {@link hasDeviceKey}
58
+ * accepts — a string with at least one non-whitespace character — always wins over this and is
59
+ * never touched; the caller checks that first.
60
+ */
61
+ readonly deviceKey: string | undefined;
62
+ }
63
+
64
+ /**
65
+ * Open one impression. Both fields are resolved EAGERLY here rather than read per post, which is the
66
+ * entire point: a lazily re-read key or identity is a per-post value wearing an impression's name.
67
+ *
68
+ * ── WHY A NON-DURABLE UNIT IS DELIBERATELY ACCEPTED HERE (ruled 0.15.1) ──────────────────────
69
+ *
70
+ * The kit's standing identity rule is that a caller writing a cross-launch join key must read
71
+ * `IdentityRecord.durable` and REFUSE a `false` — see `context/deviceId.ts`. That rule does not
72
+ * transfer to this field, and an audit that ports it here makes the data WORSE, so the reasoning is
73
+ * recorded rather than left to be rediscovered:
74
+ *
75
+ * • WHAT THE RULE PROTECTS elsewhere is a COUNTER. `app.session_started` is counted by distinct
76
+ * opens grouped on `device_key`, so a per-launch key there makes `min_sessions` structurally
77
+ * incapable of exceeding 1 — the key actively corrupts the metric. A review row is written once
78
+ * per user, so a per-launch value here cannot corrupt a count the same way.
79
+ * • WHAT REFUSING WOULD COST is the feature itself. With no unit the server's
80
+ * `_idempotency_unit` returns nothing, it DISCARDS the key and inserts a plain row — so the
81
+ * `unsent` re-post and the abandonment net each write a SECOND review row, double-counting
82
+ * `count` and corrupting `avg`. That is the exact corruption the key was added to prevent, and
83
+ * it is a corruption traded for an under-count, which is the wrong direction.
84
+ * • THE SCOPE THE UNIT ACTUALLY NEEDS is one impression inside one launch, and that is precisely
85
+ * what a pinned in-memory value guarantees. Durability buys nothing the server ever reads here.
86
+ * • THE ORPHANED-JOIN WORRY (a review row stamped with a key no analytics event carries) is real
87
+ * and is NOT closed by any of the above — it is an ID-SPACE problem, not a durability one, and
88
+ * it survives here. Two sequences still produce it, both of them ordinary:
89
+ * (1) the impression opens BEFORE any surface has registered a real `appId`, so it pins the
90
+ * ambient fallback while every later analytics event carries the host's id. This is the
91
+ * same cold-start ordering the ambient reader was fixed for, and the test that covers
92
+ * that fix calls it the sequence rather than an edge case.
93
+ * (2) the impression opens while a hydration is IN FLIGHT, so it pins the synchronous mint
94
+ * and the persisted id replaces it in the registry a moment later.
95
+ * What WAS closed is a different and larger failure: the ambient reader used to answer
96
+ * `undefined` permanently once the real `appId` registered, which cost every later impression
97
+ * its unit outright. The orphan that remains is bounded to impressions opened before the
98
+ * process's id space settles, and the gate is once-per-user, so in practice it is at most the
99
+ * first row.
100
+ * • AND THE ORPHAN IS NOT AN ARGUMENT FOR REFUSING, which is the trap. Dropping the unit does not
101
+ * repair the join — it removes it AND removes the server's ability to dedupe, so an orphaned
102
+ * row that is at least idempotent becomes a row that is neither. Closing it properly means
103
+ * giving the gate a settled identity to pin, which is a public-API change (`appId` / `storage`
104
+ * props on `ReviewGate`) and is deliberately not attempted here.
105
+ */
106
+ export const createImpression = (): ReviewImpression => ({
107
+ idempotencyKey: mintIdempotencyKey(),
108
+ deviceKey: ambientAutoDeviceKey(),
109
+ });
@@ -55,8 +55,10 @@ export { requestStoreReview, openStoreListing, storeUrl } from "./storeReview";
55
55
  // hand-rolling a decision fetch: a host-rolled one that collapses `{fire:false}` into undefined
56
56
  // makes the server able to turn prompts ON but never OFF (the 2026-07-16 first-session incident).
57
57
  // `submitReview` resolves with a THREE-state `ReviewSubmitResult` (accepted / rejected / unsent),
58
- // not a boolean: only `unsent` may be retried, because the wire has no idempotency key and a
59
- // re-post after a non-2xx that already committed writes a second row.
58
+ // not a boolean: only `unsent` is retried today. A body built by `ReviewGate` now carries an
59
+ // `idempotency_key` plus the unit the server scopes it to, so that re-post upserts one row instead
60
+ // of writing a second; whether a `rejected` verdict should ALSO retry is a separate recovery-budget
61
+ // decision and is deliberately unchanged.
60
62
  export { submitReview, fetchReviewDecision, reportAppEvent } from "./transport";
61
63
  export type {
62
64
  FetchReviewDecisionOptions,
@@ -5,8 +5,8 @@
5
5
  * mounted CoachmarkProvider gets review gating for free — no second storage to wire.
6
6
  */
7
7
  import type { CoachmarkStorage } from "../coachmarks/types";
8
- import { getCoachmarkStorage } from "../coachmarks/runtime";
9
- import { getCurrentSessionId } from "../analytics/currentSession";
8
+ import { getCoachmarkStorage, validateGateStorage } from "../coachmarks/runtime";
9
+ import { getCurrentSessionId, isMintedSessionId } from "../analytics/currentSession";
10
10
  import { makeSessionId } from "../analytics/reportClientEvent";
11
11
 
12
12
  /** Once-gate key. Keyed by app version when `oncePerVersion` is on, so a new release re-enables. */
@@ -22,10 +22,12 @@ export const reviewSessionsKey = (id: string): string => `wire_review_${id}_sess
22
22
  /** Companion key holding the open id the counter was LAST incremented for (see {@link bumpSessionCount}). */
23
23
  export const reviewSessionOpenKey = (id: string): string => `wire_review_${id}_open`;
24
24
 
25
- /** Resolve the storage to use: an explicit override, else the provider-injected singleton. */
25
+ /** Resolve the storage to use: an explicit override, else the provider-injected singleton — both
26
+ * routed through the thenable probe, so an ASYNC adapter resolves to the fail-closed substitute
27
+ * (see `validateGateStorage` in coachmarks/runtime) instead of feeding Promises to `readInt`. */
26
28
  export const resolveStorage = (
27
29
  override?: CoachmarkStorage | null,
28
- ): CoachmarkStorage | null => override ?? getCoachmarkStorage();
30
+ ): CoachmarkStorage | null => validateGateStorage(override ?? getCoachmarkStorage());
29
31
 
30
32
  /** RN sets this global; absent under node/SSR. Read defensively via {@link warnMissingGateStorage}. */
31
33
  declare const __DEV__: boolean | undefined;
@@ -154,12 +156,26 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
154
156
  // that was live when the pin was taken, and read it lazily.
155
157
  //
156
158
  // • pin unset → pin = the registered id ?? a freshly minted one, and remember which
157
- // registered id that was (`undefined` when we had to mint).
158
- // remembered `undefined` the FIRST registration merely NAMES the open the gate already
159
- // counted under a minted id. Adopt it, leave the PIN alone. This is
159
+ // registered id that was (`undefined` when we had to mint) and whether
160
+ // it was itself a MINTED fallback.
161
+ // remembered `undefined`
162
+ // or remembered MINTED → the FIRST REAL registration merely NAMES the open the gate already
163
+ // counted under a fallback id. Adopt it, leave the PIN alone. This is
160
164
  // the cold-start case, and it stays shut.
161
165
  // • live id ≠ remembered → a genuinely new app-open. Re-pin to it; the counter bumps once.
162
166
  //
167
+ // ── A MINTED ID IS NOT AN APP-OPEN (the second half of the same rule) ────────────────────────
168
+ // The bullet above says "remembered MINTED" because the registry has TWO writers, not one.
169
+ // `reportSessionStart` registers a real open — but `ensureCurrentSessionId()` also WRITES the shared
170
+ // slot, with a fallback id it minted so a fire-and-forget event is not dropped for want of a
171
+ // `session_id`, and it is reached from `analyticsFacade.resolveSessionId`, `wireActivation.track`
172
+ // and `reviews/transport.reportAppEvent`. A host can hit any of those before `useLifecycleEvents`
173
+ // mounts. That made the pin's `observed` a minted id rather than `undefined`, so the cold-start
174
+ // branch never fired and the real `app.session_started` that followed fell through to the last
175
+ // branch as "a genuinely new app-open": ONE launch counted as TWO, and `minSessions: 2` became
176
+ // satisfiable inside a user's FIRST session — failing in the UNSAFE direction. `isMintedSessionId`
177
+ // is the seam that tells the two writers apart; a fallback is not evidence the app was opened.
178
+ //
163
179
  // ⚠️ ONE ACCEPTED MISS, deliberately in the safe direction: if the mount open never registers (a
164
180
  // refused non-durable device key over a broken store) and a LATER foreground is the first
165
181
  // registration the launch ever sees, that registration is ADOPTED rather than counted, costing one
@@ -182,6 +198,12 @@ type OpenPin = {
182
198
  openId: string;
183
199
  /** The registered session id observed at pin time; `undefined` when `openId` was minted. */
184
200
  observed: string | undefined;
201
+ /**
202
+ * True when `observed` was a FALLBACK id `ensureCurrentSessionId` minted, not a real app-open.
203
+ * Such a pin has to behave exactly like `observed === undefined`: nothing has named this open yet,
204
+ * so the first REAL registration adopts it rather than starting a second one.
205
+ */
206
+ observedMinted: boolean;
185
207
  };
186
208
 
187
209
  type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: OpenPin };
@@ -196,28 +218,37 @@ const openIdGlobal = globalThis as GlobalWithOpenId;
196
218
  */
197
219
  export const currentOpenId = (): string => {
198
220
  const live = getCurrentSessionId();
221
+ // A FALLBACK id is not an app-open (see the note above `OpenPin.observedMinted`), so it is
222
+ // recorded as such and never treated as a registration this pin could be measured against.
223
+ const liveMinted = isMintedSessionId(live);
199
224
  const pin = openIdGlobal[PROCESS_OPEN_ID_SLOT];
200
225
 
201
226
  // First read of the launch. Adopt the registered id when the host wired lifecycle BEFORE any gate
202
227
  // rendered — that is the same unit the server counts — otherwise mint one and record that we did.
203
228
  if (!pin) {
204
229
  const openId = live ?? makeSessionId();
205
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live };
230
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId, observed: live, observedMinted: liveMinted };
206
231
  return openId;
207
232
  }
208
233
 
209
234
  // Nothing registered yet, or still the same registered open: the pin stands.
210
235
  if (!live || live === pin.observed) return pin.openId;
211
236
 
212
- // The first registration of the launch NAMES the open we already pinned under a minted id, so it
213
- // is adopted, not counted. Re-pinning here is exactly the cold-start double-count described above.
214
- if (pin.observed === undefined) {
215
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: pin.openId, observed: live };
237
+ // The first REAL registration of the launch NAMES the open we already pinned whether we pinned
238
+ // under an id this function minted (`observed === undefined`) or under one `ensureCurrentSessionId`
239
+ // minted for an early event (`observedMinted`). Either way nothing had named the open yet, so it is
240
+ // adopted, not counted. Re-pinning here is exactly the cold-start double-count described above.
241
+ if (pin.observed === undefined || pin.observedMinted) {
242
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = {
243
+ openId: pin.openId,
244
+ observed: live,
245
+ observedMinted: liveMinted,
246
+ };
216
247
  return pin.openId;
217
248
  }
218
249
 
219
250
  // A second, DIFFERENT registration is a genuinely new app-open.
220
- openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live };
251
+ openIdGlobal[PROCESS_OPEN_ID_SLOT] = { openId: live, observed: live, observedMinted: liveMinted };
221
252
  return live;
222
253
  };
223
254
 
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { ensureCurrentSessionId } from "../analytics/currentSession";
18
18
  import { buildEventsRequest, type ClientEvent } from "../analytics/reportClientEvent";
19
+ import { DEADLINE_EXPIRED, withDeadline } from "../utils/withDeadline";
19
20
  import type { SubmitResult } from "../utils/submitResult";
20
21
  import type { ReviewDecisionResponse, ReviewSubmission, ReviewTarget } from "./types";
21
22
 
@@ -45,23 +46,28 @@ export type ReviewSubmitResult = SubmitResult;
45
46
  *
46
47
  * It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
47
48
  * "nothing reached the server" and "the server answered non-2xx", and the one caller that reads
48
- * this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. But the server mints
49
- * its own row id (`create_review` / `_new_id()`) and `CreateReviewRequest` carries no id, so there
50
- * is NO idempotency key on the wire: a 502 returned AFTER the insert commits means the re-post
51
- * writes a SECOND row, double-counting `count` and corrupting `avg` the precise corruption the
52
- * one-row latch exists to prevent. A response of any status proves the server was reached, and
53
- * that is a different question from whether it liked the row. So the two are different values.
49
+ * this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. At the time there was
50
+ * no idempotency key on the wire at all, so a 502 returned AFTER the insert commits meant the
51
+ * re-post wrote a SECOND row, double-counting `count` and corrupting `avg` the precise corruption
52
+ * the one-row latch exists to prevent. A response of any status proves the server was reached, and
53
+ * that is a different question from whether it liked the row. So the two are different values, and
54
+ * they stay different: the split is what lets the caller decide, and the retry-on-`rejected`
55
+ * question is a recovery-budget decision the key does not answer on its own.
54
56
  *
55
57
  * ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
56
58
  *
57
59
  * `unsent` is not proof the server never got the row. A connection dropped after the request was
58
60
  * written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
59
- * an offline device. Retrying only on `unsent` is therefore SAFER, not SAFE: it removes the
60
- * double-post the server itself told us about, and leaves the narrow window where the answer never
61
- * made it back onto the wire. Closing that window needs a CLIENT-MINTED IDEMPOTENCY KEY the server
62
- * upserts on, which is a server change (`CreateReviewRequest` + `create_review`) and is not
63
- * something the kit can fake. Until it exists, prefer losing a row over inventing one: a lost
64
- * detractor is a gap in the data, a duplicated one is a lie in the data.
61
+ * an offline device. That used to make the `unsent` re-post SAFER rather than SAFE, and it is what
62
+ * the client-minted idempotency key now covers: `ReviewSubmission.idempotency_key` reaches
63
+ * `CreateReviewRequest`, and the server upserts the derived row rather than inserting a second one.
64
+ *
65
+ * IT IS A CAPABILITY, NOT A GUARANTEE, and the condition is not ours to enforce from in here. The
66
+ * server honours the key only when the post also carries a unit it can scope it to
67
+ * `meta.device_key`, else `session_id` — and ignores it outright otherwise. `ReviewGate` fills the
68
+ * device key in for exactly this reason, but this function posts whatever body it is handed, so a
69
+ * caller that assembles its own body without a unit is back in the old world where a lost detractor
70
+ * is a gap in the data and a duplicated one is a lie in the data.
65
71
  */
66
72
  export const submitReview = async (
67
73
  target: ReviewTarget | undefined,
@@ -72,11 +78,19 @@ export const submitReview = async (
72
78
  const url = `${target.serverUrl.replace(/\/$/, "")}/v1/reviews`;
73
79
  const headers: Record<string, string> = { "Content-Type": "application/json" };
74
80
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
75
- const res = await fetch(url, {
76
- method: "POST",
77
- headers,
78
- body: JSON.stringify(review),
79
- });
81
+ // Under a deadline (see `utils/withDeadline`): a hung POST used to hold `ReviewGate`'s
82
+ // `postOnce` latch closed for the ~60s platform default, which is exactly the window the
83
+ // unmount recovery net cannot re-post inside.
84
+ const res = await withDeadline((signal) =>
85
+ fetch(url, {
86
+ method: "POST",
87
+ headers,
88
+ body: JSON.stringify(review),
89
+ signal,
90
+ }),
91
+ );
92
+ // A deadline that expired is not a rejection: nothing reached the server, so it stays re-postable.
93
+ if (res === DEADLINE_EXPIRED) return "unsent";
80
94
  // No response object at all is not an answer — treat it as nothing having reached the server
81
95
  // rather than as a rejection, or a stubbed-out `fetch` would silently latch the row away.
82
96
  if (!res) return "unsent";
@@ -188,9 +202,14 @@ export const fetchReviewDecision = async (
188
202
  const url = `${base}/v1/reviews/decision${qs ? `?${qs}` : ""}`;
189
203
  const headers: Record<string, string> = {};
190
204
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
191
- const res = await fetch(url, { headers });
192
- if (!res || !res.ok) return null;
193
- const json = (await res.json()) as ReviewDecisionResponse | null;
205
+ // The BODY read is inside the deadline too: a ceiling cleared when the headers land is not a
206
+ // ceiling (the `fetchWireFeatures` lesson see `utils/withDeadline`).
207
+ const json = await withDeadline(async (signal): Promise<ReviewDecisionResponse | null> => {
208
+ const res = await fetch(url, { headers, signal });
209
+ if (!res || !res.ok) return null;
210
+ return (await res.json()) as ReviewDecisionResponse | null;
211
+ });
212
+ if (json === DEADLINE_EXPIRED) return null;
194
213
  // A body without a boolean `fire` is not a decision. Guard it explicitly rather than
195
214
  // letting `{}` through as a truthy object that `decideReview` would treat as a verdict
196
215
  // (`{}.fire === undefined` is falsy, so it would silently read as "never fire").
@@ -97,6 +97,13 @@ export interface ReviewSubmission {
97
97
  contact?: string;
98
98
  session_id?: string;
99
99
  source?: string;
100
+ /**
101
+ * OPTIONAL client-minted key for the two-phase submit: two posts carrying the same key upsert ONE
102
+ * row instead of creating two. The server honours it ONLY when the post also carries a resolvable
103
+ * user — `meta.device_key`, else `session_id` — and silently ignores it otherwise, so it always
104
+ * travels with one of those. One key per gate impression, stable across that impression's posts.
105
+ */
106
+ idempotency_key?: string;
100
107
  meta?: Record<string, unknown>;
101
108
  }
102
109
 
@@ -12,10 +12,11 @@
12
12
  * (`wire_review_<id>_seen`) always applies locally, so a server bug can never spam the prompt.
13
13
  * The global `isTestingCoachmark` flag force-shows the gate for QA replay.
14
14
  */
15
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
16
16
 
17
+ import { getCurrentSessionId, subscribeCurrentSessionId } from "../analytics/currentSession";
17
18
  import { hasSeenGate, isCoachmarkTesting, markSeenGate } from "../coachmarks/runtime";
18
- import { useResolvedFeatures } from "../features/WireFeaturesProvider";
19
+ import { useResolvedFeaturesState } from "../features/WireFeaturesProvider";
19
20
  import { decideReview, evaluateGate, resolveRules } from "./decision";
20
21
  import { sameDecision, shallowEqual } from "./equality";
21
22
  import {
@@ -67,7 +68,19 @@ export const useReviewGate = ({
67
68
 
68
69
  // The review MASTER switch. Disabled → the gate never fires, composing as an extra AND over
69
70
  // the local rules + the server decision seam below (fail-open: defaults to enabled).
70
- const featureEnabled = useResolvedFeatures({ flags: features, config: featuresConfig }).review.enabled;
71
+ //
72
+ // `settled` is read too, and it is the half that makes the switch real. The flags start on the
73
+ // all-on defaults and swap only when the fetch resolves, so `featureEnabled` is `true` on the
74
+ // FIRST render of every mount — including a tenant with `review.enabled: false`. With no
75
+ // `timeoutFallbackMs` the local wait is skipped as well (`elapsed` starts `true`), so the gate
76
+ // could become visible, fire `review_prompt_shown`, and take a POSTed row from a user the tenant
77
+ // had switched off. The comment below ("must never see the gate") was falsified by its own async
78
+ // default. Holding the FIRST impression until an answer exists is what honours the switch.
79
+ const { flags: resolvedFeatures, settled: featuresSettled } = useResolvedFeaturesState({
80
+ flags: features,
81
+ config: featuresConfig,
82
+ });
83
+ const featureEnabled = resolvedFeatures.review.enabled;
71
84
 
72
85
  const testing = isTesting ?? isCoachmarkTesting();
73
86
  const seenKey = reviewSeenKey(config.id, config.oncePerVersion === false ? undefined : config.appVersion);
@@ -75,19 +88,51 @@ export const useReviewGate = ({
75
88
  const sessionsKey = reviewSessionsKey(config.id);
76
89
  const sessionOpenKey = reviewSessionOpenKey(config.id);
77
90
 
91
+ // ── FOLLOW real app-opens, do not sample one ────────────────────────────────────────────────
92
+ //
93
+ // `bumpSessionCount` is IDEMPOTENT per app-open and moves once per open — but it used to be reached
94
+ // ONLY from the `useState` initializer below, which runs once per component INSTANCE. On a screen
95
+ // that never unmounts (a home feed, a tab that stays alive) that meant once per PROCESS, and iOS
96
+ // suspends rather than kills: `useLifecycleEvents` fires a fresh `app.session_started` on every
97
+ // foreground past its threshold and the SERVER's `min_sessions` advances, while `wire_review_<id>_sessions`
98
+ // stayed at its launch value and the LOCAL `minSessions` rule was unsatisfiable for the life of the
99
+ // app. Silent, and in the safe direction (a prompt that never shows), which is why it survived —
100
+ // `sessionCountAcrossOpens.test.ts` proved the FUNCTION advances and could not see that the HOOK
101
+ // never asked it again.
102
+ //
103
+ // So subscribe to the registry that knows. A notification naming the same open is a no-op by
104
+ // construction (`bumpSessionCount` reads its stored count back), so the cold-start ordering this
105
+ // pin exists for is untouched.
106
+ const openSessionId = useSyncExternalStore(
107
+ subscribeCurrentSessionId,
108
+ getCurrentSessionId,
109
+ getCurrentSessionId,
110
+ );
111
+ // Read through a ref: a host that passes `storage={{ … }}` inline (idiomatic React, and what
112
+ // `frequent_rules` #11 requires this hook to tolerate) mints a fresh identity every render, and
113
+ // listing it in the deps below would re-run the effect on every one of them.
114
+ const storageRef = useRef(storage);
115
+ storageRef.current = storage;
116
+
78
117
  // Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount: `bumpSessionCount`
79
118
  // keys off the live per-open session id (or a per-process id when no host wired the lifecycle
80
119
  // events), so a remount, a navigation return, or React StrictMode's dev double-invoke of this
81
120
  // initializer all read the same number back instead of inflating it. Before this, `sessions`
82
121
  // counted mounts, so the fail-closed `minSessions: 2` default could be satisfied inside the user's
83
122
  // very first app open — the exact scenario it was added to prevent.
84
- const sessions = useState(() => {
123
+ const [sessions, setSessions] = useState(() => {
85
124
  const store = resolveStorage(storage);
86
125
  // No storage pins the counter at 1 forever, so the fail-closed minSessions rule can never be
87
126
  // met and the gate silently never fires. Dev-only, once per process.
88
127
  warnMissingGateStorage(store, "review");
89
128
  return bumpSessionCount(store, sessionsKey, sessionOpenKey);
90
- })[0];
129
+ });
130
+ useEffect(() => {
131
+ const next = bumpSessionCount(resolveStorage(storageRef.current), sessionsKey, sessionOpenKey);
132
+ // A bail-out when the count is unchanged, so a notification for an open already counted costs
133
+ // nothing: React skips the re-render when the state is identical.
134
+ setSessions((prev) => (prev === next ? prev : next));
135
+ }, [openSessionId, sessionsKey, sessionOpenKey]);
91
136
 
92
137
  // Gate the local rules behind an optional client-side timeout, so a reachable server
93
138
  // gets a window to answer first. A present `decision` bypasses the wait entirely.
@@ -123,10 +168,15 @@ export const useReviewGate = ({
123
168
  const resolved = decideReview(local, decision);
124
169
  // (3) the once-gate always wins locally, even over a server "fire".
125
170
  const alreadySeen = hasSeenGate(seenKey, storage, isTesting);
126
- const ready = hasServerDecision || elapsed;
171
+ // FAIL-OPEN IS NOT WEAKENED BY THIS. `featuresSettled` is `false` only while a fetch that could
172
+ // still change the answer is in flight; it is `true` with no `featuresConfig` (nothing to ask),
173
+ // with explicit `features`, and — the case that matters — the moment a FAILED fetch resolves,
174
+ // because `fetchWireFeatures` swallows a timeout / 401 / 5xx into the all-on defaults. So an
175
+ // unreachable control plane still allows the gate; only an unanswered one holds it.
176
+ const ready = (hasServerDecision || elapsed) && featuresSettled;
127
177
  return { visible: ready && resolved.fire && !alreadySeen, verdict: resolved };
128
178
  // eslint-disable-next-line react-hooks/exhaustive-deps
129
- }, [featureEnabled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
179
+ }, [featureEnabled, featuresSettled, testing, config, decision, events, sessions, elapsed, hasServerDecision, seenKey, lastKey, storage, isTesting]);
130
180
 
131
181
  // Stable callbacks + a stable controller object, so a host can safely list any of
132
182
  // them in its own effect deps without the effect re-firing every render.
@@ -173,8 +173,24 @@ const emitFirstOpen = (opts: ReportFirstOpenOptions): void => {
173
173
  * • Race guard: an in-memory latch is set SYNCHRONOUSLY before the async read, so two
174
174
  * near-simultaneous calls fire at most once.
175
175
  * • Without `storage`: degraded mode — fires once per PROCESS via the latch only (documented).
176
+ * • WITHOUT A TRANSPORT: no-op, and — the point of the check — the once-ever flag is NOT spent.
177
+ * See below.
176
178
  */
177
179
  export const reportFirstOpen = (opts: ReportFirstOpenOptions): void => {
180
+ // NO TRANSPORT, NO SPEND. `emitFirstOpen` routes through `buildEventsRequest`, which returns
181
+ // `null` when there is no `target.serverUrl` — so with neither a `sink` nor a server URL the emit
182
+ // is a silent no-op. Both once-ever guards were still consumed underneath it: the in-memory latch
183
+ // synchronously, and then the PERSISTED `wireai:first_open:<appId>` flag on the write below. That
184
+ // marks the install as having reported its first open when NOTHING was ever sent, and the flag
185
+ // survives app kills — so `app.first_open` was dead for that install FOREVER, with no error and
186
+ // no event. Reachable on the documented wiring: a `LifecycleConfig` carrying `storage` + `appId`
187
+ // whose `serverUrl` is optional and resolves late (the hook's effect is mount-only).
188
+ //
189
+ // The sibling emitter already gets this right — `reportSessionStart` bails on
190
+ // `!opts.sink && !target?.serverUrl` BEFORE its own once-per-open guard — and this is the same
191
+ // check in the same position, for the same reason. A guard may only be spent by a real send.
192
+ if (!opts.sink && !opts.target?.serverUrl) return;
193
+
178
194
  const appId = opts.appId ?? "default";
179
195
 
180
196
  // Synchronous latch FIRST: guarantees at-most-one before any await (same-process race guard) and
@@ -36,7 +36,12 @@ import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../anal
36
36
  import { hydrateDeviceIdentity } from "../context/deviceId";
37
37
  import { resolveIdentity } from "../identity/identityRecord";
38
38
  import { collectDeviceContext } from "../device/deviceContext";
39
- import type { WireOnboardingStorage } from "../session/persistedSession";
39
+ import {
40
+ READ_TIMED_OUT,
41
+ READ_TIMEOUT_MS,
42
+ withTimeout,
43
+ type WireOnboardingStorage,
44
+ } from "../session/persistedSession";
40
45
  import { reportFirstOpen } from "./lifecycle";
41
46
  import { reportSessionStart } from "./reportSessionStart";
42
47
  import { BACKGROUND_SESSION_MS } from "./useSessionStart";
@@ -173,6 +178,23 @@ export const useLifecycleEvents = (
173
178
  * emits the event with NO auto key — the same verdict `<WireOnboarding>` reaches on a broken
174
179
  * adapter, and strictly better than a key that differs on every launch. Never rejects: the read
175
180
  * resolves to a record or `undefined`, never a throw.
181
+ *
182
+ * ── AND IT IS CAPPED, BECAUSE "NEVER REJECTS" IS NOT "ALWAYS SETTLES" ──────────────────────
183
+ *
184
+ * `hydrateDeviceIdentity` awaits a BARE `storage.getItem`, so an adapter that HANGS (the read
185
+ * neither resolves nor rejects — a locked keychain, a wedged native bridge) leaves the promise
186
+ * pending forever. Every fire in this hook lives inside that `.then`, so a hung adapter did not
187
+ * degrade the device key: it silently killed `app.session_started` AND `app.first_open` for the
188
+ * whole process, on every launch. Those two events are the funnel's denominator and what the
189
+ * server counts `min_sessions` from, so `min_sessions` stays 0 and every review / questionnaire
190
+ * trigger becomes unsatisfiable — with nothing logged and nothing thrown.
191
+ *
192
+ * The read is therefore raced against the SAME ceiling the rest of the kit reads through
193
+ * (`session/persistedSession`'s exported `withTimeout` + `READ_TIMEOUT_MS`, which is also the
194
+ * ceiling `<WireOnboarding>`'s auto-join gate names). Blowing it fires with NO auto key —
195
+ * byte-identical to the refusal a rejecting adapter already gets — because a read that did not
196
+ * answer cannot prove the id is durable, and an event with no device key is a recoverable gap
197
+ * while no event at all is a permanent one.
176
198
  */
177
199
  const openAutoDeviceKey = (fire: (autoDeviceKey: string | undefined) => void): void => {
178
200
  const { config: cfg, options: opts } = latest.current;
@@ -182,8 +204,14 @@ export const useLifecycleEvents = (
182
204
  fire(undefined);
183
205
  return;
184
206
  }
185
- void hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }).then((identity) => {
207
+ void withTimeout(
208
+ hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }),
209
+ READ_TIMEOUT_MS,
210
+ ).then((read) => {
186
211
  if (cancelled) return;
212
+ // READ_TIMED_OUT = the adapter never answered in time. It collapses onto the SAME branch as
213
+ // a non-durable record — fire with no auto key — so the events still go out, degraded.
214
+ const identity = read === READ_TIMED_OUT ? undefined : read;
187
215
  fire(identity?.durable ? identity.value : undefined);
188
216
  });
189
217
  };
@@ -31,7 +31,12 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
31
31
  import type { ClientEventTarget } from "../analytics/reportClientEvent";
32
32
  import { hydrateDeviceIdentity } from "../context/deviceId";
33
33
  import { collectDeviceContext } from "../device/deviceContext";
34
- import type { WireOnboardingStorage } from "../session/persistedSession";
34
+ import {
35
+ READ_TIMED_OUT,
36
+ READ_TIMEOUT_MS,
37
+ withTimeout,
38
+ type WireOnboardingStorage,
39
+ } from "../session/persistedSession";
35
40
  import { reportSessionStart } from "./reportSessionStart";
36
41
 
37
42
  /** A foreground after at least this long in the background counts as a NEW app-open (30 min). */
@@ -125,6 +130,15 @@ export const useSessionStart = (
125
130
  * rule `context/deviceId.ts` states for exactly these callers: *"Callers that write a key onto the
126
131
  * wire as a cross-launch join must read `durable` and refuse a `false`."* A host-supplied key or a
127
132
  * config with no `storage` still fires synchronously — there is nothing to read.
133
+ *
134
+ * AND THE WAIT IS CAPPED. `hydrateDeviceIdentity` awaits a BARE `storage.getItem`, so an adapter
135
+ * that HANGS (neither resolves nor rejects) leaves that promise pending forever — and since the
136
+ * only fire path in this hook lives inside the `.then`, `app.session_started` then never fires at
137
+ * all, for the whole process, on every launch. That is the event the server counts `min_sessions`
138
+ * from, so the counter stays 0 and every server-side trigger becomes unsatisfiable, silently. The
139
+ * read is raced against the kit's shared storage ceiling (`session/persistedSession`'s exported
140
+ * `withTimeout` + `READ_TIMEOUT_MS`, the same one `<WireOnboarding>`'s auto-join gate names), and
141
+ * blowing it fires with NO auto key — exactly the branch a rejecting adapter already takes.
128
142
  */
129
143
  const openAutoDeviceKey = (fireOpen: (autoDeviceKey: string | undefined) => void): void => {
130
144
  const { config: cfg, options: opts } = latest.current;
@@ -134,8 +148,14 @@ export const useSessionStart = (
134
148
  fireOpen(undefined);
135
149
  return;
136
150
  }
137
- void hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }).then((identity) => {
151
+ void withTimeout(
152
+ hydrateDeviceIdentity({ appId: cfg.appId, storage: cfg.storage }),
153
+ READ_TIMEOUT_MS,
154
+ ).then((read) => {
138
155
  if (cancelled) return;
156
+ // READ_TIMED_OUT = the adapter never answered in time. Same branch as a non-durable record:
157
+ // fire with no auto key, so the app-open is still counted.
158
+ const identity = read === READ_TIMED_OUT ? undefined : read;
139
159
  fireOpen(identity?.durable ? identity.value : undefined);
140
160
  });
141
161
  };