@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
@@ -5,13 +5,21 @@
5
5
  * network resolves. When no `serverUrl`+`apiKey` is supplied it never fetches and just returns
6
6
  * defaults, so a host that never adopts flags sees ZERO behavior change and ZERO network calls.
7
7
  *
8
+ * ⚠️ THE OPTIMISTIC DEFAULT IS A GUESS, AND SOME CONSUMERS CANNOT ACT ON A GUESS. Fail-open is the
9
+ * right resting state, but "all on because we have not asked yet" and "all on because the tenant
10
+ * says so" are different answers and the returned flags cannot tell them apart. A surface whose
11
+ * first render is IRREVERSIBLE — the review gate posts a `review_prompt_shown` and can take a row
12
+ * the moment it appears — needs the difference, or a tenant with `review.enabled: false` still gets
13
+ * one impression on every launch before the answer lands. {@link useWireFeaturesState} exposes it as
14
+ * `settled`; `useWireFeatures` is the flags-only wrapper and is unchanged.
15
+ *
8
16
  * frequent_rules #11 (unstable inline props): the config is idiomatically passed inline
9
17
  * (`config={{ serverUrl, apiKey }}`), a fresh identity every render. We stabilize the fetch key
10
18
  * by VALUE so the effect fires once per real credential change, and we only swap the returned
11
19
  * flags object when its VALUES change — so a host that lists the returned flags in an effect dep
12
20
  * array settles in a bounded number of commits instead of looping (enforced by the canary suite).
13
21
  */
14
- import { useEffect, useRef, useState } from "react";
22
+ import { useEffect, useMemo, useRef, useState } from "react";
15
23
 
16
24
  import { defaultWireFeatures, featuresEqual } from "./defaults";
17
25
  import { fetchWireFeatures } from "./fetchWireFeatures";
@@ -28,16 +36,35 @@ const useStableValue = <T,>(value: T, isEqual: (a: T, b: T) => boolean): T => {
28
36
  const sameFetchKey = (a: WireFeaturesConfig | undefined, b: WireFeaturesConfig | undefined): boolean =>
29
37
  a?.serverUrl === b?.serverUrl && a?.apiKey === b?.apiKey && a?.appId === b?.appId;
30
38
 
31
- export const useWireFeatures = (config?: WireFeaturesConfig): WireFeatures => {
39
+ /**
40
+ * The flags PLUS whether they are an answer yet — the shape `useWireFeatures` returns half of.
41
+ *
42
+ * `settled: false` means "nobody has answered; these are the optimistic defaults". It becomes
43
+ * `true` the moment the fetch resolves (fail-open included: an unreachable control plane resolves
44
+ * to the defaults and that IS a settled answer, because it is the answer we are keeping), and it
45
+ * starts `true` whenever there is nothing to wait for — no creds, so no flag can ever arrive.
46
+ */
47
+ export interface WireFeaturesState {
48
+ flags: WireFeatures;
49
+ /** `false` only while a fetch that could still change the answer is in flight. */
50
+ settled: boolean;
51
+ }
52
+
53
+ export const useWireFeaturesState = (config?: WireFeaturesConfig): WireFeaturesState => {
32
54
  const stableConfig = useStableValue(config, sameFetchKey);
33
55
  const [flags, setFlags] = useState<WireFeatures>(defaultWireFeatures);
34
56
 
35
57
  const canFetch = !!stableConfig?.serverUrl && !!stableConfig?.apiKey;
58
+ // Nothing to fetch → the defaults are already the final answer, so a consumer that waits for an
59
+ // answer must not wait forever. Only ever moves to `true`: a later credential change refreshes a
60
+ // KNOWN answer, and un-answering it would re-hide a surface the user is already looking at.
61
+ const [settled, setSettled] = useState<boolean>(!canFetch);
36
62
 
37
63
  useEffect(() => {
38
64
  if (!canFetch) {
39
65
  // No creds → stay on the all-on defaults (and reset if creds were removed).
40
66
  setFlags((prev) => (featuresEqual(prev, defaultWireFeatures) ? prev : defaultWireFeatures));
67
+ setSettled(true);
41
68
  return;
42
69
  }
43
70
  let alive = true;
@@ -47,11 +74,20 @@ export const useWireFeatures = (config?: WireFeaturesConfig): WireFeatures => {
47
74
  // Only adopt a NEW reference on a real value change, so a host effect keyed on the
48
75
  // returned flags doesn't loop (frequent_rules #11).
49
76
  setFlags((prev) => (featuresEqual(prev, next) ? prev : next));
77
+ // Settled on EVERY resolution, including the fail-open one: `fetchWireFeatures` swallows a
78
+ // timeout / 401 / 5xx into the defaults, and that fallback is the final answer, not a
79
+ // pending one. This is what keeps fail-open intact — a broken control plane resolves fast
80
+ // and permissive, it does not leave a gated surface waiting.
81
+ setSettled(true);
50
82
  });
51
83
  return () => {
52
84
  alive = false;
53
85
  };
54
86
  }, [canFetch, stableConfig]);
55
87
 
56
- return flags;
88
+ return useMemo(() => ({ flags, settled }), [flags, settled]);
57
89
  };
90
+
91
+ /** The flags alone — the original hook, unchanged for every consumer that cannot act on `settled`. */
92
+ export const useWireFeatures = (config?: WireFeaturesConfig): WireFeatures =>
93
+ useWireFeaturesState(config).flags;
@@ -96,6 +96,20 @@ const provenanceRegistry = (): ProvenanceRegistry => {
96
96
  const provenanceKey = (space: IdentitySpace, scope?: string): string =>
97
97
  `${space}:${scope ?? "default"}`;
98
98
 
99
+ /**
100
+ * Is this candidate a usable identity value? A string with at least one non-whitespace character.
101
+ *
102
+ * ⛔ PURE, and that is the whole point of it existing separately from {@link resolveIdentity}: it is
103
+ * the predicate a READ-ONLY caller needs. `resolveIdentity` cannot serve that caller, because for a
104
+ * `host`-sourced input it WRITES to the process provenance registry — so merely asking it "is this a
105
+ * usable key?" would enrol the asker in the census it was only trying to read. That is the exact
106
+ * defect 0.15.1 fixed (a propless reader minting into `registry.keys` made the census read two, and
107
+ * the reader answered `undefined` forever), so the predicate is shared rather than re-typed:
108
+ * `resolveIdentity` below is its only other caller, so the two can never drift.
109
+ */
110
+ export const isUsableIdentityValue = (value: unknown): value is string =>
111
+ typeof value === "string" && value.trim().length > 0;
112
+
99
113
  /**
100
114
  * Build an {@link IdentityRecord} from a candidate value, or `undefined` when there is nothing usable
101
115
  * (a non-string, or blank after trimming) — so a caller can `if (record)`-gate instead of guessing
@@ -107,9 +121,8 @@ const provenanceKey = (space: IdentitySpace, scope?: string): string =>
107
121
  * key" from a silent third id space into a warnable condition. Never throws.
108
122
  */
109
123
  export const resolveIdentity = (input: ResolveIdentityInput): IdentityRecord | undefined => {
110
- if (typeof input.value !== "string") return undefined;
124
+ if (!isUsableIdentityValue(input.value)) return undefined;
111
125
  const value = input.value.trim();
112
- if (!value) return undefined;
113
126
  const durable = input.durable ?? input.source === "host";
114
127
  if (input.source === "host") {
115
128
  provenanceRegistry().host.set(provenanceKey(input.space, input.scope), value);
@@ -236,6 +236,31 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
236
236
  const postedRef = useRef(false);
237
237
  /** The last submission fired with no answer yet. Non-null after an UNSENT post = still owed. */
238
238
  const unackedRef = useRef<QuestionnaireSubmission | null>(null);
239
+ /**
240
+ * THE NET WAS INERT WHILE THE POST WAS IN FLIGHT, which is precisely when it is needed.
241
+ *
242
+ * This is the review-gate defect on its second copy: 0.15.1 closed it in `reviews/ReviewGate.tsx`
243
+ * and left the twin here open. The latch is taken OPTIMISTICALLY and only released in the `.then`
244
+ * below, while `finish` auto-closes the modal 1500ms after the post. So on any network slower than
245
+ * that — the offline answerer, a stalled radio, a captive portal — the unmount ran with the latch
246
+ * still CLOSED: `recoverRef`'s only branch requires `!postedRef.current`, so it no-opped, and the
247
+ * post later resolved `"unsent"` with nothing mounted to notice. The host's `onResolved` →
248
+ * `markResolved` has ALREADY written the permanent `wire_questionnaire_<id>_<ver>_seen` key, so
249
+ * that user is never asked again and their answers are gone. Silent. The existing canary missed it
250
+ * because every case awaits the submit BEFORE unmounting — the fast path, where the latch has
251
+ * already reopened.
252
+ *
253
+ * So the unmount hands the recovery to the promise: when it finds a post STILL IN FLIGHT — the one
254
+ * state its branch cannot serve — it arms `recoverOnSettleRef`, and the `.then` below does the
255
+ * re-post if nothing reached the server. It is armed ONLY in that state, so the fast path is
256
+ * untouched and still recovers exactly once, in the net. `recoveredRef` bounds this to exactly ONE
257
+ * extra post — the same in-process ceiling the net always had, never a retry loop.
258
+ */
259
+ const recoverOnSettleRef = useRef(false);
260
+ const recoveredRef = useRef(false);
261
+ /** Latest `postOnce`, so the recovery above can call it from inside its own promise chain
262
+ * (the same ref convention `recoverRef` below uses) without a self-referencing callback. */
263
+ const postOnceRef = useRef<(body: QuestionnaireSubmission) => void>(() => {});
239
264
  const postOnce = useCallback(
240
265
  (body: QuestionnaireSubmission) => {
241
266
  if (postedRef.current) return;
@@ -245,6 +270,12 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
245
270
  if (result === "unsent") {
246
271
  // Nothing reached the server → un-latch, so these answers can still go out.
247
272
  postedRef.current = false;
273
+ // The gate is already gone: the unmount net ran while this post was still in flight and
274
+ // found the latch closed, so nothing else will ever recover this row. Do it here, once.
275
+ if (recoverOnSettleRef.current && !recoveredRef.current) {
276
+ recoveredRef.current = true;
277
+ postOnceRef.current(body);
278
+ }
248
279
  return;
249
280
  }
250
281
  // `accepted` OR `rejected`: the server answered, so it owns this row. Never re-post it.
@@ -253,6 +284,7 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
253
284
  },
254
285
  [target, id],
255
286
  );
287
+ postOnceRef.current = postOnce;
256
288
 
257
289
  /**
258
290
  * The recovery point for a submission the server never ANSWERED — never for one it answered
@@ -268,7 +300,14 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
268
300
  const recoverRef = useRef<() => void>(() => {});
269
301
  recoverRef.current = () => {
270
302
  const undelivered = unackedRef.current;
271
- if (undelivered && !postedRef.current) postOnce(undelivered);
303
+ if (undelivered && !postedRef.current) {
304
+ postOnce(undelivered);
305
+ return;
306
+ }
307
+ // STILL IN FLIGHT (latch closed, row unanswered) — the state that made this net inert. The
308
+ // branch above cannot act from here, so the pending post is asked to recover itself when it
309
+ // settles unsent. Nothing else is scheduled to look at this row again.
310
+ if (postedRef.current && undelivered) recoverOnSettleRef.current = true;
272
311
  };
273
312
  React.useEffect(() => () => recoverRef.current(), []);
274
313
 
@@ -10,6 +10,7 @@
10
10
  * non-2xx (incl. 404), a missing `fetch`, or bad JSON all resolve to null, so a host wiring
11
11
  * pure server-directed firing simply shows nothing - exactly the reviews decision seam.
12
12
  */
13
+ import { DEADLINE_EXPIRED, withDeadline } from "../utils/withDeadline";
13
14
  import type { SubmitResult } from "../utils/submitResult";
14
15
  import type {
15
16
  QuestionnaireDecisionResponse,
@@ -63,11 +64,19 @@ export const submitQuestionnaireResponse = async (
63
64
  const url = `${base}/v1/questionnaires/${encodeURIComponent(id)}/responses`;
64
65
  const headers: Record<string, string> = { "Content-Type": "application/json" };
65
66
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
66
- const res = await fetch(url, {
67
- method: "POST",
68
- headers,
69
- body: JSON.stringify(submission),
70
- });
67
+ // Under a deadline (see `utils/withDeadline`): a hung POST used to hold `QuestionnaireGate`'s
68
+ // `postOnce` latch closed for the ~60s platform default, which is exactly the window the
69
+ // unmount recovery net cannot re-post inside.
70
+ const res = await withDeadline((signal) =>
71
+ fetch(url, {
72
+ method: "POST",
73
+ headers,
74
+ body: JSON.stringify(submission),
75
+ signal,
76
+ }),
77
+ );
78
+ // A deadline that expired is not a rejection: nothing reached the server, so it stays re-postable.
79
+ if (res === DEADLINE_EXPIRED) return "unsent";
71
80
  // No response object at all is not an answer — treat it as nothing having reached the server
72
81
  // rather than as a rejection, or a stubbed-out `fetch` would silently latch the answers away.
73
82
  if (!res) return "unsent";
@@ -107,9 +116,14 @@ export const fetchQuestionnaireDecision = async (
107
116
  const url = `${base}/v1/questionnaires/decision${qs ? `?${qs}` : ""}`;
108
117
  const headers: Record<string, string> = {};
109
118
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
110
- const res = await fetch(url, { headers });
111
- if (!res || !res.ok) return null;
112
- const json = (await res.json()) as QuestionnaireDecisionResponse | null;
119
+ // The BODY read is inside the deadline too: a ceiling cleared when the headers land is not a
120
+ // ceiling (the `fetchWireFeatures` lesson see `utils/withDeadline`).
121
+ const json = await withDeadline(async (signal): Promise<QuestionnaireDecisionResponse | null> => {
122
+ const res = await fetch(url, { headers, signal });
123
+ if (!res || !res.ok) return null;
124
+ return (await res.json()) as QuestionnaireDecisionResponse | null;
125
+ });
126
+ if (json === DEADLINE_EXPIRED) return null;
113
127
  // A body without a boolean `fire` is not a decision. Guard it explicitly (mirrors
114
128
  // `fetchReviewDecision`) rather than letting `{}` through as a truthy object a gate would treat
115
129
  // as a verdict (`{}.fire === undefined` is falsy, so it would silently read as "never fire").
@@ -13,10 +13,11 @@
13
13
  * the popup. The global `isTestingCoachmark` flag force-shows the gate for QA replay, and the
14
14
  * `questionnaire` master switch (a disabled tenant) wins over everything.
15
15
  */
16
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
16
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
17
17
 
18
+ import { getCurrentSessionId, subscribeCurrentSessionId } from "../analytics/currentSession";
18
19
  import { hasSeenGate, isCoachmarkTesting, markSeenGate } from "../coachmarks/runtime";
19
- import { useResolvedFeatures } from "../features/WireFeaturesProvider";
20
+ import { useResolvedFeaturesState } from "../features/WireFeaturesProvider";
20
21
  import { sameDecision, shallowEqual } from "../reviews/equality";
21
22
  import { decideQuestionnaire, evaluateGate, resolveRules } from "./decision";
22
23
  import {
@@ -64,8 +65,20 @@ export const useQuestionnaireGate = ({
64
65
 
65
66
  // The questionnaire MASTER switch. Disabled → the gate never fires, composing as an extra
66
67
  // AND over the local rules + the server decision seam below (fail-open: defaults to enabled).
67
- const featureEnabled = useResolvedFeatures({ flags: features, config: featuresConfig })
68
- .questionnaire.enabled;
68
+ //
69
+ // `settled` is read too, and it is the half that makes the switch real — the same asymmetry
70
+ // `useReviewGate` closed, on the same seam. The flags start on the all-on defaults and swap only
71
+ // when the fetch resolves, so `featureEnabled` is `true` on the FIRST render of every mount,
72
+ // including a tenant with `questionnaire.enabled: false`. With no `timeoutFallbackMs` the local
73
+ // wait is skipped as well (`elapsed` starts `true`), so the gate could become visible, fire
74
+ // `questionnaire_prompt_shown`, and POST a response from a user the tenant had switched off. The
75
+ // comment below ("must never see it") was falsified by its own async default. Holding the FIRST
76
+ // impression until an answer exists is what honours the switch.
77
+ const { flags: resolvedFeatures, settled: featuresSettled } = useResolvedFeaturesState({
78
+ flags: features,
79
+ config: featuresConfig,
80
+ });
81
+ const featureEnabled = resolvedFeatures.questionnaire.enabled;
69
82
 
70
83
  const testing = isTesting ?? isCoachmarkTesting();
71
84
  const seenKey = questionnaireSeenKey(
@@ -76,16 +89,48 @@ export const useQuestionnaireGate = ({
76
89
  const sessionsKey = questionnaireSessionsKey(config.id);
77
90
  const sessionOpenKey = questionnaireSessionOpenKey(config.id);
78
91
 
92
+ // ── FOLLOW real app-opens, do not sample one ────────────────────────────────────────────────
93
+ //
94
+ // `bumpSessionCount` is IDEMPOTENT per app-open and moves once per open — but it used to be reached
95
+ // ONLY from the `useState` initializer below, which runs once per component INSTANCE. On a screen
96
+ // that never unmounts (a home feed, a tab that stays alive) that meant once per PROCESS, and iOS
97
+ // suspends rather than kills: `useLifecycleEvents` fires a fresh `app.session_started` on every
98
+ // foreground past its threshold and the SERVER's `min_sessions` advances, while `wire_questionnaire_<id>_sessions`
99
+ // stayed at its launch value and the LOCAL `minSessions` rule was unsatisfiable for the life of the
100
+ // app. Silent, and in the safe direction (a prompt that never shows), which is why it survived —
101
+ // `sessionCountAcrossOpens.test.ts` proved the FUNCTION advances and could not see that the HOOK
102
+ // never asked it again.
103
+ //
104
+ // So subscribe to the registry that knows. A notification naming the same open is a no-op by
105
+ // construction (`bumpSessionCount` reads its stored count back), so the cold-start ordering this
106
+ // pin exists for is untouched.
107
+ const openSessionId = useSyncExternalStore(
108
+ subscribeCurrentSessionId,
109
+ getCurrentSessionId,
110
+ getCurrentSessionId,
111
+ );
112
+ // Read through a ref: a host that passes `storage={{ … }}` inline (idiomatic React, and what
113
+ // `frequent_rules` #11 requires this hook to tolerate) mints a fresh identity every render, and
114
+ // listing it in the deps below would re-run the effect on every one of them.
115
+ const storageRef = useRef(storage);
116
+ storageRef.current = storage;
117
+
79
118
  // Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount — see the same note
80
119
  // in `useReviewGate`; `bumpSessionCount` keys off the live per-open session id, so a remount or a
81
120
  // StrictMode double-invoke of this initializer reads the same number back instead of inflating it.
82
- const sessions = useState(() => {
121
+ const [sessions, setSessions] = useState(() => {
83
122
  const store = resolveStorage(storage);
84
123
  // Same as `useReviewGate`: no storage means the counter never leaves 1, so the fail-closed
85
124
  // minSessions rule is unsatisfiable and the gate silently never fires. Dev-only, once per process.
86
125
  warnMissingGateStorage(store, "questionnaire");
87
126
  return bumpSessionCount(store, sessionsKey, sessionOpenKey);
88
- })[0];
127
+ });
128
+ useEffect(() => {
129
+ const next = bumpSessionCount(resolveStorage(storageRef.current), sessionsKey, sessionOpenKey);
130
+ // A bail-out when the count is unchanged, so a notification for an open already counted costs
131
+ // nothing: React skips the re-render when the state is identical.
132
+ setSessions((prev) => (prev === next ? prev : next));
133
+ }, [openSessionId, sessionsKey, sessionOpenKey]);
89
134
 
90
135
  // Gate the local rules behind an optional client-side timeout, so a reachable server gets a
91
136
  // window to answer first. A present `decision` bypasses the wait entirely.
@@ -129,11 +174,17 @@ export const useQuestionnaireGate = ({
129
174
  const resolved = decideQuestionnaire(local, decision);
130
175
  // (3) the once-gate always wins locally, even over a server "fire".
131
176
  const alreadySeen = hasSeenGate(seenKey, storage, isTesting);
132
- const ready = hasServerDecision || elapsed;
177
+ // FAIL-OPEN IS NOT WEAKENED BY THIS. `featuresSettled` is `false` only while a fetch that could
178
+ // still change the answer is in flight; it is `true` with no `featuresConfig` (nothing to ask),
179
+ // with explicit `features`, and — the case that matters — the moment a FAILED fetch resolves,
180
+ // because `fetchWireFeatures` swallows a timeout / 401 / 5xx into the all-on defaults. So an
181
+ // unreachable control plane still allows the gate; only an unanswered one holds it.
182
+ const ready = (hasServerDecision || elapsed) && featuresSettled;
133
183
  return { visible: ready && resolved.fire && !alreadySeen, verdict: resolved };
134
184
  // eslint-disable-next-line react-hooks/exhaustive-deps
135
185
  }, [
136
186
  featureEnabled,
187
+ featuresSettled,
137
188
  testing,
138
189
  config,
139
190
  decision,
@@ -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.
@@ -158,6 +214,31 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
158
214
  const postedRef = React.useRef(false);
159
215
  /** The last submission fired with no answer yet. Non-null after an UNSENT post = still owed. */
160
216
  const unackedRef = React.useRef<ReviewSubmission | null>(null);
217
+ /**
218
+ * THE NET WAS INERT WHILE THE POST WAS IN FLIGHT, which is precisely when it is needed.
219
+ *
220
+ * The latch is taken OPTIMISTICALLY and only released in `submitReview`'s `.then`, while the gate
221
+ * auto-closes 1500ms after the post (`resolveWithDelay`). So on any network slower than that —
222
+ * the offline detractor, a stalled radio, a captive portal — the unmount ran with the latch still
223
+ * CLOSED: the recovery branch below requires `!postedRef.current`, and the abandonment branch
224
+ * returns on `postedRef.current`, so BOTH no-opped and a 1-4 rating plus its mandatory text was
225
+ * dropped in silence. The once-gate means that user is never asked again. The existing canary
226
+ * missed it because it awaits the submit BEFORE unmounting — the fast path, where the latch has
227
+ * already reopened.
228
+ *
229
+ * So the unmount hands the recovery to the promise: when it finds a post STILL IN FLIGHT — the
230
+ * one state neither of its branches can serve — it arms `recoverOnSettleRef`, and the `.then`
231
+ * below does the re-post if nothing reached the server. It is armed ONLY in that state, so the
232
+ * fast path (settled before unmount) is untouched and still recovers exactly once, in the net.
233
+ * `recoveredRef` bounds this to exactly ONE extra post — the same in-process ceiling the net
234
+ * always had, never a retry loop — and the re-post carries this impression's `idempotency_key`
235
+ * plus its unit, so a row that did land is upserted rather than duplicated.
236
+ */
237
+ const recoverOnSettleRef = React.useRef(false);
238
+ const recoveredRef = React.useRef(false);
239
+ /** Latest `postOnce`, so the recovery above can call it from inside its own promise chain
240
+ * (the same ref convention `abandonRef` below uses) without a self-referencing callback. */
241
+ const postOnceRef = React.useRef<(body: ReviewSubmission) => void>(() => {});
161
242
  const postOnce = useCallback(
162
243
  (body: ReviewSubmission) => {
163
244
  if (postedRef.current) return;
@@ -167,15 +248,23 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
167
248
  if (result === "unsent") {
168
249
  // Nothing reached the server → un-latch, so this row can still go out.
169
250
  postedRef.current = false;
251
+ // The gate is already gone: the unmount net ran while this post was still in flight and
252
+ // found the latch closed, so nothing else will ever recover this row. Do it here, once.
253
+ if (recoverOnSettleRef.current && !recoveredRef.current) {
254
+ recoveredRef.current = true;
255
+ postOnceRef.current(body);
256
+ }
170
257
  return;
171
258
  }
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.
259
+ // `accepted` OR `rejected`: the server answered, so it owns this row. Not re-posted here
260
+ // a non-2xx can follow a committed insert, and re-posting a refusal is a retry decision
261
+ // with its own budget, deliberately left out of the change that added the key.
174
262
  unackedRef.current = null;
175
263
  });
176
264
  },
177
265
  [target],
178
266
  );
267
+ postOnceRef.current = postOnce;
179
268
 
180
269
  /**
181
270
  * The abandonment safety net, and the reason forcing feedback does not COST us detractor data.
@@ -200,14 +289,20 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
200
289
  postOnce(undelivered);
201
290
  return;
202
291
  }
292
+ // STILL IN FLIGHT (latch closed, row unanswered) — the state that made this net inert. Neither
293
+ // branch can act from here, so the pending post is asked to recover itself when it settles
294
+ // unsent. Nothing else is scheduled to look at this row again.
295
+ if (postedRef.current && undelivered) {
296
+ recoverOnSettleRef.current = true;
297
+ return;
298
+ }
203
299
  if (postedRef.current || stars < 1) return;
204
300
  postOnce(
205
- buildReviewSubmission({
301
+ buildBody({
206
302
  stars,
207
303
  feedbackText: feedback,
208
304
  suggestion,
209
- sessionId,
210
- meta: { ...meta, abandoned: true },
305
+ extraMeta: { abandoned: true },
211
306
  }),
212
307
  );
213
308
  };
@@ -249,10 +344,9 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
249
344
  // the user actually left a review — this only ever means we ASKED. Never read it as
250
345
  // a review count. We await first so the row carries the route it actually took.
251
346
  postOnce(
252
- buildReviewSubmission({
347
+ buildBody({
253
348
  stars: value,
254
- sessionId,
255
- meta: { ...meta, store_route: route },
349
+ extraMeta: { store_route: route },
256
350
  }),
257
351
  );
258
352
  setPhase("thanks");
@@ -263,7 +357,7 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
263
357
  setPhase("feedback");
264
358
  }
265
359
  },
266
- [onEvent, id, store, postOnce, sessionId, meta, resolveWithDelay],
360
+ [onEvent, id, store, postOnce, buildBody, resolveWithDelay],
267
361
  );
268
362
 
269
363
  /**
@@ -284,19 +378,13 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
284
378
  if (!feedbackReady) return;
285
379
  // Identity is NEVER collected here — the host already sends user_id / device /
286
380
  // 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
- });
381
+ const body = buildBody({ stars, feedbackText: feedback, suggestion });
294
382
  postOnce(body);
295
383
  onEvent?.({ name: "review_feedback_submitted", id, stars });
296
384
  playHaptic("success");
297
385
  setPhase("thanks");
298
386
  resolveWithDelay();
299
- }, [feedbackReady, stars, feedback, suggestion, sessionId, meta, postOnce, onEvent, id, resolveWithDelay]);
387
+ }, [feedbackReady, stars, feedback, suggestion, buildBody, postOnce, onEvent, id, resolveWithDelay]);
300
388
 
301
389
  const dismiss = useCallback(() => {
302
390
  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
  };