@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
@@ -17,7 +17,11 @@
17
17
  *
18
18
  * The form NEVER asks for identity (no name / email / contact field): the host already
19
19
  * identifies the user via `sessionId` + `meta` (e.g. user_id, device_key), which is all
20
- * the POST body carries alongside the rating and the free text.
20
+ * the POST body carries alongside the rating and the free text. Since 2026-08-19 the gate also
21
+ * fills in `meta.device_key` from the kit's own per-install id when the host passed none — not for
22
+ * identification, but because it is the unit the server scopes the idempotency key to, and without
23
+ * it the key is discarded. Any non-blank string the host passes still wins verbatim. See
24
+ * `idempotency.ts`.
21
25
  *
22
26
  * Motion (2026-07-16): the three phases used to swap IN PLACE with no transition. They now
23
27
  * ride `ModalPaneHost`, the same pane-slide primitive the questionnaire steps use, and the
@@ -45,6 +49,7 @@ import { useOnboardingTheme } from "../theme/ThemeContext";
45
49
  import { bodyStyle, captionStyle, headingStyle } from "../theme/typography";
46
50
  import { buildReviewSubmission, routeRating } from "./decision";
47
51
  import { feedbackInputHeights } from "./feedbackForm";
52
+ import { createImpression, hasDeviceKey, type ReviewImpression } from "./idempotency";
48
53
  import { resolvePresentation } from "./presentation";
49
54
  import { ReviewModal, type ReviewModalHandle } from "./ReviewModal";
50
55
  import { requestStoreReview } from "./storeReview";
@@ -107,18 +112,63 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
107
112
  onResolved?.();
108
113
  }, [onResolved]);
109
114
 
115
+ /**
116
+ * ONE identity + ONE idempotency key for this whole gate impression, opened lazily at the first
117
+ * post so a prompt the user never acts on mints nothing.
118
+ *
119
+ * Both values live for the IMPRESSION, not the render and not the post: the server derives the
120
+ * row id from `(app_id, unit, key)`, so a key that moves between two posts writes two rows and a
121
+ * unit that moves does the same. `idempotency.ts` carries the full reasoning.
122
+ */
123
+ const impressionRef = React.useRef<ReviewImpression | null>(null);
124
+
125
+ /**
126
+ * Build a submission for THIS impression: the host's `meta` plus whatever the call site adds,
127
+ * stamped with the impression's key and — only when the host supplied none — its device key.
128
+ *
129
+ * A host-supplied `meta.device_key` ALWAYS wins and is never overwritten — "supplied" meaning a
130
+ * string with at least one non-whitespace character, the bar {@link hasDeviceKey} sets; a blank
131
+ * or non-string value is not a usable unit and is replaced. This mirrors the `userContext` /
132
+ * `autoJoinKey` rule one directory over, which sets the same bar through `resolveIdentity`. The
133
+ * injection exists because without a resolvable unit the server discards the key outright, and a
134
+ * home-feed gate has no `session_id` to fall back to.
135
+ */
136
+ const buildBody = useCallback(
137
+ (input: {
138
+ stars: number;
139
+ feedbackText?: string;
140
+ suggestion?: string;
141
+ extraMeta?: Record<string, unknown>;
142
+ }): ReviewSubmission => {
143
+ let impression = impressionRef.current;
144
+ if (!impression) {
145
+ impression = createImpression();
146
+ impressionRef.current = impression;
147
+ }
148
+ const merged: Record<string, unknown> = { ...meta, ...input.extraMeta };
149
+ if (!hasDeviceKey(merged) && impression.deviceKey) merged.device_key = impression.deviceKey;
150
+ return buildReviewSubmission({
151
+ stars: input.stars,
152
+ feedbackText: input.feedbackText,
153
+ suggestion: input.suggestion,
154
+ sessionId,
155
+ meta: merged,
156
+ idempotencyKey: impression.idempotencyKey,
157
+ });
158
+ },
159
+ [meta, sessionId],
160
+ );
161
+
110
162
  /**
111
163
  * EXACTLY ONE review row per gate, ever — the latch that makes "force feedback" safe.
112
164
  *
113
- * WHY ONE POST AND NOT TWO: the tempting design is to post the rating at star-tap and then
114
- * post again with the text, enriching the first row. That needs the server to accept a
115
- * client-supplied id so the second write UPSERTS. It does not, as deployed today:
116
- * `create_review` mints its own id unconditionally (`app/reviews.py` `_new_id()`) and
117
- * `CreateReviewRequest` (`app/schemas.py`) has no id field so two posts = two rows, which
118
- * double-counts `count` and corrupts `avg`. Reading the ack does not rescue that guess either:
119
- * a second post against a server with no upsert comes back 2xx HAVING created the second row, so
120
- * the failure is invisible to the caller and permanent. Post once, at the last responsible
121
- * moment, and never depend on an unmerged/undeployed server capability.
165
+ * WHY ONE POST AND NOT TWO: the tempting design is to post the rating at star-tap and then post
166
+ * again with the text, enriching the first row. The server now supports exactly that a post
167
+ * carrying `idempotency_key` upserts on a derived row id instead of inserting but the latch
168
+ * stays, because two posts buy nothing the one-at-the-last-responsible-moment post does not
169
+ * already have, and every additional post is another chance to hand the server a unit it cannot
170
+ * resolve. What the key genuinely buys is the RECOVERY paths below: the `unsent` re-post and the
171
+ * abandonment net now merge into the row they may have already written instead of duplicating it.
122
172
  *
123
173
  * ── THE LATCH IS A CLAIM, NOT A RECEIPT ────────────────────────────────────────────────────
124
174
  *
@@ -134,17 +184,23 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
134
184
  *
135
185
  * "Did it land" and "did the server answer" are DIFFERENT questions, which is why `submitReview`
136
186
  * resolves `accepted` / `rejected` / `unsent` instead of a boolean. Only `unsent` un-latches.
137
- * A non-2xx means the server was REACHED, and with no idempotency key on the wire (see above:
138
- * the id is server-minted) we cannot know whether it stored the row before failing to say so — a
139
- * gateway 502 after the insert commits looks identical to a refusal. Re-posting on that verdict
140
- * writes the second row this whole latch exists to prevent, and does it on the exact code path
141
- * meant to protect the data. So on any answer the latch stays closed and `unackedRef` is
142
- * cleared: the row is the server's problem now.
187
+ * A non-2xx means the server was REACHED, and we still cannot know whether it stored the row
188
+ * before failing to say so a gateway 502 after the insert commits looks identical to a refusal.
189
+ * That verdict is left alone DELIBERATELY (2026-08-19): re-posting on it is now safe wherever the
190
+ * key is honoured, but it would also turn a genuine 4xx refusal into a retry needing its own
191
+ * budget, so changing it is its own decision and not this one. On any answer the latch stays
192
+ * closed and `unackedRef` is cleared: the row is the server's problem now.
143
193
  *
144
- * HONEST RESIDUAL: `unsent` is not proof the server never got it either. A connection dropped
145
- * after the row committed throws here exactly like an offline device, so the one re-post below
146
- * can still duplicate. This is SAFER, not safe. The real fix is a client-minted idempotency key
147
- * the server upserts on a server change, out of scope for the kit.
194
+ * WHAT THE KEY CHANGED, STATED AS A CAPABILITY: `unsent` was never proof the server got nothing
195
+ * a connection dropped after the row committed throws here exactly like an offline device, so the
196
+ * one re-post below could duplicate. It now carries this impression's `idempotency_key` and the
197
+ * unit the server scopes it to, so the server upserts that row rather than adding a second one.
198
+ * That holds while the unit is resolvable, and a host CANNOT make it unresolvable by editing
199
+ * `meta`: `buildBody` tests the MERGED bucket, so stripping `device_key` there only gets the auto
200
+ * id re-injected. The one post that carries no unit is the one where the kit cannot name an id
201
+ * honestly — two tenants registered in one process leave `impression.deviceKey` undefined, and
202
+ * nothing is stamped. On a gate with no `session_id` that post reaches the server with no unit to
203
+ * scope the key to, so the server ignores the key and it is back in the old, duplicating world.
148
204
  *
149
205
  * NOTHING HERE IS AWAITED. `postOnce` returns synchronously and every caller advances the phase
150
206
  * on the next line, so the user is never waiting on the network to dismiss the prompt.
@@ -169,8 +225,9 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
169
225
  postedRef.current = false;
170
226
  return;
171
227
  }
172
- // `accepted` OR `rejected`: the server answered, so it owns this row. Never re-post it
173
- // a non-2xx can follow a committed insert, and the wire has no idempotency key.
228
+ // `accepted` OR `rejected`: the server answered, so it owns this row. Not re-posted here
229
+ // a non-2xx can follow a committed insert, and re-posting a refusal is a retry decision
230
+ // with its own budget, deliberately left out of the change that added the key.
174
231
  unackedRef.current = null;
175
232
  });
176
233
  },
@@ -202,12 +259,11 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
202
259
  }
203
260
  if (postedRef.current || stars < 1) return;
204
261
  postOnce(
205
- buildReviewSubmission({
262
+ buildBody({
206
263
  stars,
207
264
  feedbackText: feedback,
208
265
  suggestion,
209
- sessionId,
210
- meta: { ...meta, abandoned: true },
266
+ extraMeta: { abandoned: true },
211
267
  }),
212
268
  );
213
269
  };
@@ -249,10 +305,9 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
249
305
  // the user actually left a review — this only ever means we ASKED. Never read it as
250
306
  // a review count. We await first so the row carries the route it actually took.
251
307
  postOnce(
252
- buildReviewSubmission({
308
+ buildBody({
253
309
  stars: value,
254
- sessionId,
255
- meta: { ...meta, store_route: route },
310
+ extraMeta: { store_route: route },
256
311
  }),
257
312
  );
258
313
  setPhase("thanks");
@@ -263,7 +318,7 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
263
318
  setPhase("feedback");
264
319
  }
265
320
  },
266
- [onEvent, id, store, postOnce, sessionId, meta, resolveWithDelay],
321
+ [onEvent, id, store, postOnce, buildBody, resolveWithDelay],
267
322
  );
268
323
 
269
324
  /**
@@ -284,19 +339,13 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
284
339
  if (!feedbackReady) return;
285
340
  // Identity is NEVER collected here — the host already sends user_id / device /
286
341
  // session via `sessionId` + `meta`. We POST only the rating + the free text.
287
- const body = buildReviewSubmission({
288
- stars,
289
- feedbackText: feedback,
290
- suggestion,
291
- sessionId,
292
- meta,
293
- });
342
+ const body = buildBody({ stars, feedbackText: feedback, suggestion });
294
343
  postOnce(body);
295
344
  onEvent?.({ name: "review_feedback_submitted", id, stars });
296
345
  playHaptic("success");
297
346
  setPhase("thanks");
298
347
  resolveWithDelay();
299
- }, [feedbackReady, stars, feedback, suggestion, sessionId, meta, postOnce, onEvent, id, resolveWithDelay]);
348
+ }, [feedbackReady, stars, feedback, suggestion, buildBody, postOnce, onEvent, id, resolveWithDelay]);
300
349
 
301
350
  const dismiss = useCallback(() => {
302
351
  setPhase("thanks");
@@ -103,7 +103,14 @@ export const evaluateGate = (rules: GateRules, s: GateSignals): ReviewDecision =
103
103
  export const routeRating = (stars: number): RatingRoute =>
104
104
  stars >= 5 ? "store" : "feedback";
105
105
 
106
- /** Build the `POST /v1/reviews` body. Feedback text/contact belong ONLY here, never in events. */
106
+ /**
107
+ * Build the `POST /v1/reviews` body. Feedback text/contact belong ONLY here, never in events.
108
+ *
109
+ * STILL PURE, deliberately: `idempotencyKey` and the `meta.device_key` the server scopes it to are
110
+ * both passed IN rather than resolved here. Reaching into the device-key registry from this function
111
+ * would make the gate logic untestable in isolation, and both values are scoped to an impression
112
+ * this function cannot see — see `reviews/idempotency.ts`.
113
+ */
107
114
  export const buildReviewSubmission = (input: {
108
115
  stars: number;
109
116
  feedbackText?: string;
@@ -111,6 +118,8 @@ export const buildReviewSubmission = (input: {
111
118
  contact?: string;
112
119
  sessionId?: string;
113
120
  source?: string;
121
+ /** The impression's idempotency key. Omitted → the server mints a row id and inserts, as before. */
122
+ idempotencyKey?: string;
114
123
  meta?: Record<string, unknown>;
115
124
  }): ReviewSubmission => {
116
125
  const body: ReviewSubmission = { stars: input.stars };
@@ -122,6 +131,7 @@ export const buildReviewSubmission = (input: {
122
131
  if (contact) body.contact = contact;
123
132
  if (input.sessionId) body.session_id = input.sessionId;
124
133
  if (input.source) body.source = input.source;
134
+ if (input.idempotencyKey) body.idempotency_key = input.idempotencyKey;
125
135
  if (input.meta && Object.keys(input.meta).length > 0) body.meta = input.meta;
126
136
  return body;
127
137
  };
@@ -0,0 +1,71 @@
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
+ export const createImpression = (): ReviewImpression => ({
69
+ idempotencyKey: mintIdempotencyKey(),
70
+ deviceKey: ambientAutoDeviceKey(),
71
+ });
@@ -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,7 +5,7 @@
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";
8
+ import { getCoachmarkStorage, validateGateStorage } from "../coachmarks/runtime";
9
9
  import { getCurrentSessionId } from "../analytics/currentSession";
10
10
  import { makeSessionId } from "../analytics/reportClientEvent";
11
11
 
@@ -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;
@@ -45,23 +45,28 @@ export type ReviewSubmitResult = SubmitResult;
45
45
  *
46
46
  * It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
47
47
  * "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.
48
+ * this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. At the time there was
49
+ * no idempotency key on the wire at all, so a 502 returned AFTER the insert commits meant the
50
+ * re-post wrote a SECOND row, double-counting `count` and corrupting `avg` the precise corruption
51
+ * the one-row latch exists to prevent. A response of any status proves the server was reached, and
52
+ * that is a different question from whether it liked the row. So the two are different values, and
53
+ * they stay different: the split is what lets the caller decide, and the retry-on-`rejected`
54
+ * question is a recovery-budget decision the key does not answer on its own.
54
55
  *
55
56
  * ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
56
57
  *
57
58
  * `unsent` is not proof the server never got the row. A connection dropped after the request was
58
59
  * 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.
60
+ * an offline device. That used to make the `unsent` re-post SAFER rather than SAFE, and it is what
61
+ * the client-minted idempotency key now covers: `ReviewSubmission.idempotency_key` reaches
62
+ * `CreateReviewRequest`, and the server upserts the derived row rather than inserting a second one.
63
+ *
64
+ * IT IS A CAPABILITY, NOT A GUARANTEE, and the condition is not ours to enforce from in here. The
65
+ * server honours the key only when the post also carries a unit it can scope it to
66
+ * `meta.device_key`, else `session_id` — and ignores it outright otherwise. `ReviewGate` fills the
67
+ * device key in for exactly this reason, but this function posts whatever body it is handed, so a
68
+ * caller that assembles its own body without a unit is back in the old world where a lost detractor
69
+ * is a gap in the data and a duplicated one is a lie in the data.
65
70
  */
66
71
  export const submitReview = async (
67
72
  target: ReviewTarget | undefined,
@@ -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
 
package/src/types.ts CHANGED
@@ -256,8 +256,11 @@ export type WireOnboardingProps = {
256
256
  *
257
257
  * SINCE 0.12.2, leaving it out no longer silently empties the funnel: when you pass `storage` and
258
258
  * this prop carries no `device_key`, the kit injects its OWN per-install key — the same one the
259
- * analytics surfaces mint and persist — so the default wiring joins. Anything you DO pass wins
260
- * verbatim and is never touched. See `autoJoinKey` for the opt-out and the two cases where the kit
259
+ * analytics surfaces mint and persist — so the default wiring joins. A `device_key` you DO pass
260
+ * wins verbatim and is never touched, provided it is a string with at least one non-whitespace
261
+ * character (`resolveIdentity`) — this map also accepts numbers and booleans, and a non-string or
262
+ * all-whitespace value is not a usable join key, so it is read as absent and the auto key is
263
+ * merged over it. See `autoJoinKey` for the opt-out and the two cases where the kit
261
264
  * still cannot fill the gap (no `storage`, or you opted out), which keep warning in dev.
262
265
  *
263
266
  * Never hand-write `userContext={{ deviceKey }}`: the server's device lookup reads `device_key`,
@@ -284,8 +287,11 @@ export type WireOnboardingProps = {
284
287
  * `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session; that restores the
285
288
  * pre-0.12.2 behavior exactly (nothing injected) and the dev warning fires again.
286
289
  *
287
- * Two things this flag does NOT do. It never overrides a `device_key` you passed — a host-supplied
288
- * key always wins, whatever this is set to. And it cannot conjure a key without `storage`: with no
290
+ * Two things this flag does NOT do. It never overrides a `device_key` you actually supplied — a
291
+ * host-supplied key always wins, whatever this is set to, where "supplied" means a string with at
292
+ * least one non-whitespace character (`resolveIdentity`); a non-string or an all-whitespace value
293
+ * is read as absent and the auto key is merged over it. And it cannot conjure a key without
294
+ * `storage`: with no
289
295
  * persistence the kit's id is minted fresh every launch, and a per-launch key corrupts
290
296
  * `min_sessions` instead of merely leaving the join empty, so the kit declines to inject and warns
291
297
  * in dev instead.
@@ -21,19 +21,27 @@
21
21
  *
22
22
  * `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
23
23
  * `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
24
- * treats `false` as "still owed" and re-posts. But the server mints its own row id
25
- * (`create_review` / `_new_id()`), and neither `CreateReviewRequest` nor the questionnaire response
26
- * body carries a client id, so there is NO idempotency key on the wire: a 502 returned AFTER the
27
- * insert commits means the re-post writes a SECOND row. A response of any status proves the server
28
- * was reached, and that is a different question from whether it liked the payload.
24
+ * treats `false` as "still owed" and re-posts. At the time neither body carried a client id, so
25
+ * there was NO idempotency key on the wire on either surface: a 502 returned AFTER the insert
26
+ * commits meant the re-post wrote a SECOND row. A response of any status proves the server was
27
+ * reached, and that is a different question from whether it liked the payload which is why the
28
+ * three values stay, on both surfaces, whatever the wire gains.
29
29
  *
30
30
  * ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
31
31
  *
32
32
  * `unsent` is not proof the server never got it. A connection dropped after the request was written
33
33
  * — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
34
- * device. Retrying only on `unsent` is therefore SAFER, not SAFE. Closing that window needs a
35
- * CLIENT-MINTED IDEMPOTENCY KEY the server upserts on, which is a server change and not something
36
- * the kit can fake. Until it exists, prefer losing a row over inventing one: a lost detractor is a
37
- * gap in the data, a duplicated one is a lie in the data.
34
+ * device. Closing that window needs a client-minted idempotency key the server upserts on, and AS
35
+ * OF 2026-08-19 THE TWO SURFACES DIFFER do not read this paragraph as one fact about both:
36
+ *
37
+ * REVIEWS closed. `CreateReviewRequest` takes an optional `idempotency_key` and the server
38
+ * upserts a derived row on it, so a `ReviewGate` re-post merges instead of duplicating. It is a
39
+ * capability, not a guarantee: the server honours the key only when the post also carries a
40
+ * unit to scope it to (`meta.device_key`, else `session_id`), so a caller that strips both is
41
+ * back to the old behaviour.
42
+ * • QUESTIONNAIRES — still open, and still exactly as this paragraph used to read for both. The
43
+ * response body carries no client id and the server has no idempotency path for it at all, so
44
+ * retrying only on `unsent` there is SAFER, not SAFE. Prefer losing a row over inventing one: a
45
+ * lost detractor is a gap in the data, a duplicated one is a lie in the data.
38
46
  */
39
47
  export type SubmitResult = "accepted" | "rejected" | "unsent";