@wireai/activation 0.13.3 → 0.13.5

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 (58) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +63 -0
  3. package/INTEGRATION_PROMPT.md +7 -1
  4. package/README.md +52 -0
  5. package/dist/analytics/index.d.mts +86 -6
  6. package/dist/analytics/index.d.ts +86 -6
  7. package/dist/analytics/index.js +167 -2
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +167 -3
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/coachmarks/index.d.mts +1 -1
  12. package/dist/coachmarks/index.d.ts +1 -1
  13. package/dist/coachmarks/index.js.map +1 -1
  14. package/dist/coachmarks/index.mjs.map +1 -1
  15. package/dist/{currentSession-BoWtr3Jp.d.mts → currentSession-DgJf0fRz.d.mts} +2 -2
  16. package/dist/{currentSession-CCOMlaQ2.d.ts → currentSession-EOHU64QD.d.ts} +2 -2
  17. package/dist/{decision-Cjw0jbEj.d.mts → decision-Bgo17oH7.d.mts} +1 -1
  18. package/dist/{decision-GfpXAsk_.d.ts → decision-Bkh_LigV.d.ts} +1 -1
  19. package/dist/index.d.mts +4 -4
  20. package/dist/index.d.ts +4 -4
  21. package/dist/index.js +4 -2
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +4 -2
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/questionnaire/index.d.mts +3 -3
  26. package/dist/questionnaire/index.d.ts +3 -3
  27. package/dist/questionnaire/index.js.map +1 -1
  28. package/dist/questionnaire/index.mjs.map +1 -1
  29. package/dist/reviews/index.d.mts +5 -5
  30. package/dist/reviews/index.d.ts +5 -5
  31. package/dist/reviews/index.js +4 -2
  32. package/dist/reviews/index.js.map +1 -1
  33. package/dist/reviews/index.mjs +4 -2
  34. package/dist/reviews/index.mjs.map +1 -1
  35. package/dist/showcase/index.d.mts +1 -1
  36. package/dist/showcase/index.d.ts +1 -1
  37. package/dist/showcase/index.js.map +1 -1
  38. package/dist/showcase/index.mjs.map +1 -1
  39. package/dist/{transport-DFuPyCoJ.d.ts → transport-B_0SgCBe.d.ts} +1 -1
  40. package/dist/{transport-CefdERPs.d.mts → transport-j5gFfJhK.d.mts} +1 -1
  41. package/dist/{types-CKFhyrMu.d.mts → types-BpwiRpA8.d.mts} +1 -1
  42. package/dist/{types-CKFhyrMu.d.ts → types-BpwiRpA8.d.ts} +1 -1
  43. package/dist/{types-l2tfg23P.d.mts → types-Cju-1_jT.d.mts} +1 -1
  44. package/dist/{types-UVYt9BJe.d.ts → types-h2BZvl1t.d.ts} +1 -1
  45. package/llms.txt +1 -1
  46. package/package.json +3 -1
  47. package/src/activation/revalidation.ts +9 -1
  48. package/src/analytics/currentSession.ts +10 -0
  49. package/src/analytics/eventQueue.ts +5 -0
  50. package/src/analytics/index.ts +6 -0
  51. package/src/analytics/reportClientEvent.ts +29 -7
  52. package/src/analytics/wireDoctor.ts +330 -0
  53. package/src/coachmarks/runtime.ts +10 -1
  54. package/src/context/deviceId.ts +10 -1
  55. package/src/features/WireFeaturesProvider.tsx +8 -0
  56. package/src/identity/identityRecord.ts +9 -1
  57. package/src/reviews/runtime.ts +11 -1
  58. package/src/theme/ThemeContext.tsx +7 -0
@@ -122,10 +122,15 @@ export const makeSessionId = (): string =>
122
122
  * `{written: 0, skipped: N, errors: [{index, reason: "validation_error", field: "ts"}]}` — every
123
123
  * `app_event` from `track()`/`screen()` silently discarded behind a green response.
124
124
  *
125
- * WHY HERE and not at the queue's `stamp()`: this is the single choke point all FOUR send paths go
126
- * through (offline queue, fire-and-forget, awaitable, session-start). Converting here leaves the
127
- * queue's numeric `ts` — and therefore its identical-JSON de-dup signature — exactly as it was, and
128
- * it also converts the persisted 0.13.0 backlogs (which hold a numeric `ts`) on their way out.
125
+ * WHY HERE and not at the queue's `stamp()`: this is the single choke point all FIVE send paths go
126
+ * through (offline queue, fire-and-forget, awaitable, review transport, session-start). Converting
127
+ * here leaves the queue's numeric `ts` — and therefore its identical-JSON de-dup signature — exactly
128
+ * as it was, and it also converts the persisted 0.13.0 backlogs (which hold a numeric `ts`) on their
129
+ * way out.
130
+ *
131
+ * ⚠️ NOT the same list as the ack-consumer list in {@link warnOnSkippedEvents}, which is FOUR. The
132
+ * review transport builds its request here but fires and forgets without reading the response, so
133
+ * it rides this conversion and is absent from that one. Count the call sites before editing either.
129
134
  *
130
135
  * NEVER mutates the caller's event: an event that needs a change is copied. A string `ts` (a
131
136
  * caller-set ISO stamp) and an absent `ts` pass through untouched. A non-finite (`NaN`/`Infinity`)
@@ -154,17 +159,30 @@ const toWireEvents = (events: ClientEvent[]): ClientEvent[] =>
154
159
  *
155
160
  * It is also where the internal epoch-ms `ts` becomes the wire's ISO8601 string — see
156
161
  * {@link toWireEvents} for why the conversion belongs at this choke point.
162
+ *
163
+ * `options.dryRun` asks the server to VALIDATE the batch and write nothing, which is what the wire
164
+ * doctor's round-trip check needs: a real POST, through this one builder, that cannot pollute a
165
+ * tenant's funnel. The `dry_run` key is added ONLY on an explicit `true`, so every production
166
+ * caller (which passes no options at all) still serializes the exact same bytes it did before the
167
+ * parameter existed. That byte-identity is asserted in `buildEventsRequest.test.ts`; every shipped
168
+ * send path rides this envelope, so a diagnostic is not allowed to change it for them.
157
169
  */
158
170
  export const buildEventsRequest = (
159
171
  target: { serverUrl: string; apiKey?: string } | undefined,
160
172
  events: ClientEvent[],
173
+ options?: { dryRun?: boolean },
161
174
  ): { url: string; init: RequestInit } | null => {
162
175
  if (!target?.serverUrl || events.length === 0) return null;
163
176
  try {
164
177
  const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
165
178
  const headers: Record<string, string> = { "Content-Type": "application/json" };
166
179
  if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
167
- const body = JSON.stringify({ events: toWireEvents(events) });
180
+ // The key is CONDITIONALLY assigned rather than set to a falsy default: an absent property and
181
+ // a `dry_run: false` property do not serialize the same, and only absence keeps the body byte
182
+ // identical for the production call sites.
183
+ const envelope: { events: ClientEvent[]; dry_run?: true } = { events: toWireEvents(events) };
184
+ if (options?.dryRun === true) envelope.dry_run = true;
185
+ const body = JSON.stringify(envelope);
168
186
  return { url, init: { method: "POST", headers, body } };
169
187
  } catch {
170
188
  // URL construction or JSON serialization failed — nothing to send.
@@ -176,7 +194,7 @@ export const buildEventsRequest = (
176
194
  * each entry carrying `{index, reason, field}` — `field` names the property that failed validation
177
195
  * (it is what identified the `ts` rejection), so it is folded into the reason string here. Still
178
196
  * read defensively: an older server sends no `errors` at all. */
179
- type EventsAck = { written?: number; skipped: number; reasons: string[] };
197
+ export type EventsAck = { written?: number; skipped: number; reasons: string[] };
180
198
 
181
199
  /**
182
200
  * Read the `/v1/events` ACK body. Resolves `undefined` when there is nothing readable — no `.json`
@@ -186,8 +204,12 @@ type EventsAck = { written?: number; skipped: number; reasons: string[] };
186
204
  *
187
205
  * ⚠️ The body can be read exactly once, so a caller that both decides on `skipped` AND warns must do
188
206
  * both from ONE call to this.
207
+ *
208
+ * Exported from this MODULE (not from the `./analytics` barrel) so the wire doctor can read an ack
209
+ * with the same reader the send paths use. A second ack reader would be a second thing to drift,
210
+ * and the drift it exists to catch is exactly the kind that hides behind a 200.
189
211
  */
190
- const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
212
+ export const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
191
213
  try {
192
214
  const json = (res as { json?: () => Promise<unknown> } | null | undefined)?.json;
193
215
  if (typeof json !== "function") return undefined;
@@ -0,0 +1,330 @@
1
+ /**
2
+ * wireDoctor: a DEV-ONLY, opt-in self-check that proves a fresh integration end to end before the
3
+ * first real user ever runs it.
4
+ *
5
+ * WHY IT EXISTS: the `/v1/events` endpoint answers HTTP **200** for a batch it throws away. It
6
+ * reports the refusal in the response body (`{written, skipped, errors:[{reason, field}]}`), so a
7
+ * mis-wired integration looks perfectly healthy from the outside while every event evaporates. A
8
+ * numeric `ts` did exactly that through 0.13.0. This turns that class of loss from something you
9
+ * discover in a funnel report weeks later into something the first run tells you.
10
+ *
11
+ * SHAPE: four independent checks, each a `{name, ok, detail}` unit that can be read (and tested)
12
+ * without the others. The report is data, never a thrown error and never a side effect on the host:
13
+ *
14
+ * 1. `target` : is there a server URL and a key, and do they look like a key and a URL?
15
+ * 2. `reachability`: is the server actually there? (`GET /v1/events/contract`, public and cheap)
16
+ * 3. `storage` : can the offline queue persist? (a write / read / delete probe)
17
+ * 4. `round_trip` : does a REAL event survive REAL server validation? (a `dry_run` POST)
18
+ *
19
+ * NEVER THROWS, under any input, any network condition, or any hostile response object. A doctor
20
+ * that can crash the screen it is diagnosing is worse than no doctor.
21
+ *
22
+ * ⛔ NEVER PRINTS A SECRET. The `target` check reports the key's SHAPE (present / absent, length,
23
+ * whether the prefix is the expected one) and never any character of the key itself, in any
24
+ * `detail`, any log line, or any error. `wireDoctor.test.ts` asserts that with a sentinel key.
25
+ *
26
+ * COSTS A NON-CALLER NOTHING: it is a plain function behind the `./analytics` subpath, the package
27
+ * is `sideEffects: false`, and this module has no top-level side effects, so a host that never
28
+ * imports it never bundles it. It also mints no `globalThis` slot and retains no timer or listener.
29
+ *
30
+ * USAGE (dev builds only):
31
+ *
32
+ * import { wireDoctor } from "@wireai/activation/analytics";
33
+ *
34
+ * const report = await wireDoctor({
35
+ * target: { serverUrl: "https://api.example.com", apiKey: DRIVELINE_KEY },
36
+ * storage: AsyncStorage,
37
+ * });
38
+ * console.log(report.ok, report.checks);
39
+ */
40
+ // Imported DIRECTLY from the transport module, never through `./index`: the barrel would pull the
41
+ // whole analytics surface into anything that touches the doctor. `readEventsAck` is module-exported
42
+ // for exactly this, so the doctor reads an ack with the SAME reader the send paths use rather than
43
+ // growing a second one to drift.
44
+ import {
45
+ buildEventsRequest,
46
+ makeSessionId,
47
+ readEventsAck,
48
+ type ClientEvent,
49
+ type ClientEventTarget,
50
+ } from "./reportClientEvent";
51
+ import type { WireOnboardingStorage } from "../session/persistedSession";
52
+
53
+ /** RN sets this global; absent under node/SSR. Read defensively, exactly as `warnOnSkippedEvents` does. */
54
+ declare const __DEV__: boolean | undefined;
55
+
56
+ /** One diagnosis. `name` is stable and machine-readable; `detail` is for a human reading a console. */
57
+ export type WireDoctorCheck = {
58
+ /**
59
+ * Stable id: `target` | `reachability` | `storage` | `round_trip` | `dev_only` | `internal_error`.
60
+ *
61
+ * `dev_only` means ONE thing and only that thing: `__DEV__` is unset or false, so nothing ran.
62
+ * `internal_error` is the separate catch-all for a failure that got past every check's own
63
+ * swallow. They are distinct names because a consumer branching on `dev_only` would otherwise
64
+ * read an internal fault as "this is a release build" and report a healthy skip.
65
+ */
66
+ name: string;
67
+ ok: boolean;
68
+ /** Human-readable result. ⛔ Never contains any part of an API key. */
69
+ detail: string;
70
+ };
71
+
72
+ /** What {@link wireDoctor} resolves to. `ok` is true only when EVERY check passed. */
73
+ export type WireDoctorReport = {
74
+ ok: boolean;
75
+ checks: WireDoctorCheck[];
76
+ };
77
+
78
+ /** Input for {@link wireDoctor}. `storage` is optional: without it the queue runs in-memory only. */
79
+ export type WireDoctorOptions = {
80
+ /** The same `{serverUrl, apiKey}` the kit is configured with. */
81
+ target: ClientEventTarget | undefined;
82
+ /**
83
+ * The host storage the offline queue would use (AsyncStorage-compatible). Omit it and the storage
84
+ * check reports the DEGRADED in-memory mode rather than failing.
85
+ */
86
+ storage?: WireOnboardingStorage;
87
+ };
88
+
89
+ /**
90
+ * The probe's own storage key. Deliberately NOT under the queue's `wireai:evtq:` namespace, so a
91
+ * doctor run can never read, overwrite or delete a pending backlog. A diagnostic that eats a user's
92
+ * unsent events is a worse bug than the one it was written to find.
93
+ */
94
+ const PROBE_STORAGE_KEY = "wireai:doctor:probe";
95
+
96
+ /** The `question_key` the synthetic round-trip event carries. `dry_run` means it is never written. */
97
+ const PROBE_QUESTION_KEY = "wire_doctor_probe";
98
+
99
+ /** Tenant keys start with this. Used ONLY for a boolean comparison; ⛔ never interpolated into a detail. */
100
+ const EXPECTED_KEY_PREFIX = "wai_";
101
+
102
+ /** Ceiling on each network check, so a hung server degrades to a failed check, never a hung caller. */
103
+ const NETWORK_TIMEOUT_MS = 10_000;
104
+
105
+ const check = (name: string, ok: boolean, detail: string): WireDoctorCheck => ({ name, ok, detail });
106
+
107
+ /**
108
+ * `fetch` with a timeout that is ALWAYS cleared, including on rejection. Resolves `undefined`
109
+ * instead of throwing, so every caller stays on the happy path. Returns no timer to the caller and
110
+ * leaves none pending: a diagnostic must not keep the JS thread alive after it has answered.
111
+ */
112
+ const fetchWithTimeout = async (url: string, init?: RequestInit): Promise<Response | undefined> => {
113
+ if (typeof fetch === "undefined") return undefined;
114
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : undefined;
115
+ const timer = setTimeout(() => controller?.abort(), NETWORK_TIMEOUT_MS);
116
+ try {
117
+ return await fetch(url, controller ? { ...init, signal: controller.signal } : init);
118
+ } catch {
119
+ // Unreachable host, DNS failure, abort, or a missing fetch implementation.
120
+ return undefined;
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ };
125
+
126
+ /**
127
+ * CHECK 1: is the target usable at all?
128
+ *
129
+ * Reports the key's shape and NEVER its content: present/absent, character length, and whether the
130
+ * prefix matches what a tenant key starts with. Length and a yes/no are enough to tell "you pasted
131
+ * the wrong string" from "you pasted nothing", which is the entire diagnostic value here.
132
+ */
133
+ const checkTarget = (target: ClientEventTarget | undefined): WireDoctorCheck => {
134
+ const serverUrl = target?.serverUrl;
135
+ if (typeof serverUrl !== "string" || serverUrl.trim().length === 0) {
136
+ return check("target", false, "no serverUrl configured: set `serverUrl` on the kit config.");
137
+ }
138
+ let parsedHost = false;
139
+ try {
140
+ const parsed = new URL(serverUrl);
141
+ parsedHost = parsed.protocol === "http:" || parsed.protocol === "https:";
142
+ } catch {
143
+ parsedHost = false;
144
+ }
145
+ if (!parsedHost) {
146
+ return check("target", false, "serverUrl is not a valid http(s) URL.");
147
+ }
148
+ const apiKey = target?.apiKey;
149
+ if (typeof apiKey !== "string" || apiKey.length === 0) {
150
+ return check("target", false, "serverUrl looks valid, but no apiKey is configured.");
151
+ }
152
+ // ⛔ `EXPECTED_KEY_PREFIX` is compared, never printed: it is itself a substring of a real key.
153
+ const prefixOk = apiKey.startsWith(EXPECTED_KEY_PREFIX);
154
+ return check(
155
+ "target",
156
+ true,
157
+ `serverUrl is a valid http(s) URL; apiKey present (${apiKey.length} chars, expected prefix: ${
158
+ prefixOk ? "yes" : "no"
159
+ }).`,
160
+ );
161
+ };
162
+
163
+ /**
164
+ * CHECK 2: is the server there, and is it a Wire server?
165
+ *
166
+ * `GET /v1/events/contract` is public, cheap, and it answers the question the round-trip check
167
+ * cannot answer on its own: a failure here means "wrong URL / server down", not "bad payload".
168
+ */
169
+ const checkReachability = async (serverUrl: string): Promise<WireDoctorCheck> => {
170
+ const url = `${serverUrl.replace(/\/$/, "")}/v1/events/contract`;
171
+ const res = await fetchWithTimeout(url, { method: "GET" });
172
+ if (!res) {
173
+ return check("reachability", false, "could not reach the server (network error or timeout).");
174
+ }
175
+ const status = (res as { status?: unknown }).status;
176
+ const ok = !!(res as { ok?: boolean }).ok;
177
+ if (!ok) {
178
+ return check(
179
+ "reachability",
180
+ false,
181
+ `the server answered ${typeof status === "number" ? status : "an error"} for the events contract; ` +
182
+ "check the serverUrl points at a Wire server.",
183
+ );
184
+ }
185
+ return check("reachability", true, "the server answered the events contract.");
186
+ };
187
+
188
+ /**
189
+ * CHECK 3: can the offline queue actually persist?
190
+ *
191
+ * Write, read back, compare. Without working storage the queue silently degrades to in-memory only,
192
+ * so a backgrounded app loses whatever it had not flushed, and nothing anywhere says so.
193
+ *
194
+ * The probe key is REMOVED in a `finally`, so a failing read or a throwing adapter still cleans up.
195
+ */
196
+ const checkStorage = async (storage: WireOnboardingStorage | undefined): Promise<WireDoctorCheck> => {
197
+ if (!storage) {
198
+ return check(
199
+ "storage",
200
+ true,
201
+ "no storage injected: the queue runs in DEGRADED in-memory mode and loses pending events on an app kill. " +
202
+ "Pass AsyncStorage (or an MMKV wrapper) to make it durable.",
203
+ );
204
+ }
205
+ const token = `probe_${Date.now().toString(36)}`;
206
+ try {
207
+ await storage.setItem(PROBE_STORAGE_KEY, token);
208
+ const read = await storage.getItem(PROBE_STORAGE_KEY);
209
+ if (read !== token) {
210
+ return check(
211
+ "storage",
212
+ false,
213
+ "the storage adapter accepted a write but did not read the same value back, so the queue cannot persist.",
214
+ );
215
+ }
216
+ // ⚠️ "write / read" only: this returns BEFORE the `finally` runs its delete, and a throwing
217
+ // `removeItem` is deliberately swallowed there, so the delete is attempted, never asserted.
218
+ return check("storage", true, "storage write / read round trip succeeded.");
219
+ } catch {
220
+ return check("storage", false, "the storage adapter threw, so the queue cannot persist events.");
221
+ } finally {
222
+ // Always, including after a failed write or read: never leave the probe key behind.
223
+ try {
224
+ await storage.removeItem(PROBE_STORAGE_KEY);
225
+ } catch {
226
+ // A remove that throws is not worth failing the run over; nothing else depends on it.
227
+ }
228
+ }
229
+ };
230
+
231
+ /**
232
+ * CHECK 4: does a real event survive real server validation?
233
+ *
234
+ * POSTs ONE synthetic event through the REAL {@link buildEventsRequest} with `dry_run: true`, so it
235
+ * travels the exact bytes a production event travels and the server validates it exactly the same
236
+ * way, but writes nothing. The probe is inert: it pollutes no funnel and no metric.
237
+ *
238
+ * ⚠️ The verdict is `written === 1 && skipped === 0`, read from the ACK BODY. A 200 is not a
239
+ * receipt here: the endpoint returns 200 for a batch it discarded. A `ts`-shaped drift shows up as
240
+ * `skipped: 1` with the server's own `field: "ts"`, which is the whole point of this check.
241
+ */
242
+ const checkRoundTrip = async (target: ClientEventTarget): Promise<WireDoctorCheck> => {
243
+ const event: ClientEvent = {
244
+ event_type: "app_event",
245
+ session_id: makeSessionId(),
246
+ question_key: PROBE_QUESTION_KEY,
247
+ };
248
+ const req = buildEventsRequest(target, [event], { dryRun: true });
249
+ if (!req) {
250
+ return check("round_trip", false, "could not build the events request from this target.");
251
+ }
252
+ const res = await fetchWithTimeout(req.url, req.init);
253
+ if (!res) {
254
+ return check("round_trip", false, "the events POST failed (network error or timeout).");
255
+ }
256
+ if (!(res as { ok?: boolean }).ok) {
257
+ const status = (res as { status?: unknown }).status;
258
+ return check(
259
+ "round_trip",
260
+ false,
261
+ `the events endpoint answered ${typeof status === "number" ? status : "an error"}; ` +
262
+ "a 401 here means the apiKey is not accepted.",
263
+ );
264
+ }
265
+ const ack = await readEventsAck(res);
266
+ if (!ack) {
267
+ return check(
268
+ "round_trip",
269
+ false,
270
+ "the server answered 2xx but sent no readable ack body, so it is unknown whether the event validated.",
271
+ );
272
+ }
273
+ if (ack.written === 1 && ack.skipped === 0) {
274
+ return check("round_trip", true, "the server validated the probe event: written=1, skipped=0.");
275
+ }
276
+ // The server's own reasons, verbatim, each already carrying the field it refused.
277
+ return check(
278
+ "round_trip",
279
+ false,
280
+ `the server DISCARDED the probe event: written=${ack.written ?? "unknown"}, skipped=${ack.skipped}` +
281
+ (ack.reasons.length > 0
282
+ ? `, reasons: ${ack.reasons.join(", ")}.`
283
+ : ". It gave no reason; check the server's ingest log for this request."),
284
+ );
285
+ };
286
+
287
+ /**
288
+ * Run the full diagnosis. Resolves a report; NEVER throws and NEVER rejects.
289
+ *
290
+ * DEV-ONLY BY CONTRACT: the `__DEV__` guard is the FIRST statement, so a release build performs no
291
+ * network call, no storage write, and no work at all. The report says it was skipped rather than
292
+ * pretending everything passed, because a green report that never ran is the exact failure mode
293
+ * this whole feature exists to remove.
294
+ */
295
+ export const wireDoctor = async (options: WireDoctorOptions): Promise<WireDoctorReport> => {
296
+ if (typeof __DEV__ === "undefined" || !__DEV__) {
297
+ return {
298
+ ok: false,
299
+ checks: [
300
+ check(
301
+ "dev_only",
302
+ false,
303
+ "wireDoctor is dev-only and did NOT run: __DEV__ is unset or false. Nothing was checked.",
304
+ ),
305
+ ],
306
+ };
307
+ }
308
+ try {
309
+ const target = options?.target;
310
+ const checks: WireDoctorCheck[] = [checkTarget(target)];
311
+ // Reachability and the round trip both need a usable target; running them against a broken one
312
+ // would report a network failure and bury the real cause, which check 1 already named.
313
+ if (checks[0]!.ok && target) {
314
+ checks.push(await checkReachability(target.serverUrl));
315
+ checks.push(await checkStorage(options?.storage));
316
+ checks.push(await checkRoundTrip(target));
317
+ } else {
318
+ checks.push(await checkStorage(options?.storage));
319
+ }
320
+ return { ok: checks.every((c) => c.ok), checks };
321
+ } catch {
322
+ // Belt and braces: every check above already swallows its own failures, so reaching here means
323
+ // something hostile got past them. Still a report, still not a throw. The name is
324
+ // `internal_error`, NOT `dev_only`: this ran and broke, which is the opposite of "never ran".
325
+ return {
326
+ ok: false,
327
+ checks: [check("internal_error", false, "wireDoctor could not complete: an unexpected error was swallowed.")],
328
+ };
329
+ }
330
+ };
@@ -27,7 +27,16 @@
27
27
  */
28
28
  import type { CoachmarkStorage } from "./types";
29
29
 
30
- /** Well-known key into the runtime-global symbol registry — one coachmark runtime across bundles. */
30
+ /**
31
+ * Well-known key into the runtime-global symbol registry — one coachmark runtime across bundles.
32
+ *
33
+ * @globalSlot LATCH — the RUNTIME RECORD is created once and its identity is then stable. A second
34
+ * write replaces the record `subscribeCoachmarksEnabled` registered its listeners on, so a
35
+ * kill-switch flip notifies an orphaned set while a tour that believes it is running paints nothing
36
+ * — the exact limbo `coachmarkKillSwitch` canaries. Its FIELDS (`storage`, `testing`,
37
+ * `coachmarksEnabled`, `listeners`) are live, which is why every accessor calls `coachmarkRuntime()`
38
+ * instead of holding the record, and why `coachmarksEnabled` is subscribable rather than sampled.
39
+ */
31
40
  const COACHMARK_RUNTIME_SLOT: unique symbol = Symbol.for("@wireai/activation:coachmarkRuntime");
32
41
 
33
42
  /** The shared runtime: the provider-injected storage, the QA replay flag, and the kill switch. */
@@ -68,7 +68,16 @@ export const mintDeviceId = (): string => {
68
68
  // events are the ONLY ones the server counts `min_sessions` from, so a per-launch id there is not a
69
69
  // millisecond of noise, it is a counter that can never exceed 1.
70
70
 
71
- /** Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle. */
71
+ /**
72
+ * Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle.
73
+ *
74
+ * @globalSlot LATCH — the REGISTRY OBJECT is created once and its identity is then stable. A second
75
+ * write empties `keys`/`hydrating`/`pending`, so the next surface mints a SECOND auto id for one
76
+ * install and the `device_key` the server joins sessions on splits in two — the halved-counter
77
+ * defect this registry exists to close. Its CONTENTS are live (the id per `appId` is replaced when
78
+ * an async hydration adopts a persisted value), so callers must re-read through
79
+ * `resolveAutoDeviceKey()` rather than hold the string a mount happened to see first.
80
+ */
72
81
  const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoDeviceKeys");
73
82
 
74
83
  /** What a hydration settled on: the id, and whether persistence actually CONFIRMED it.
@@ -18,6 +18,14 @@ import { defaultWireFeatures } from "./defaults";
18
18
  import { useWireFeatures } from "./useWireFeatures";
19
19
  import type { WireFeatures, WireFeaturesConfig } from "./types";
20
20
 
21
+ /**
22
+ * @globalSlot LATCH — the React context object is minted by the first copy of this module to load
23
+ * and every later copy ADOPTS it (the `if (!…)` guard below). A second write would re-split the
24
+ * identity the guard exists to keep whole: the provider would publish into one context while a
25
+ * subpath bundle's `useContext` read another, and `useResolvedFeatures`' null fallback would serve
26
+ * the all-on defaults with no crash and no warning. Because it is a latch, and only because of
27
+ * that, the module-local `const WireFeaturesContext` below may cache it.
28
+ */
21
29
  const CONTEXT_SYMBOL = Symbol.for("wireai.features.context");
22
30
  // `globalThis`, not `global`: the other nine `Symbol.for` slots in this kit all use it, and `global`
23
31
  // simply does not exist in a plain browser (RN-web / SSR-in-the-browser), where this line would
@@ -65,7 +65,15 @@ export type ResolveIdentityInput = {
65
65
  scope?: string;
66
66
  };
67
67
 
68
- /** Well-known key into the runtime-global symbol registry — one provenance registry per process. */
68
+ /**
69
+ * Well-known key into the runtime-global symbol registry — one provenance registry per process.
70
+ *
71
+ * @globalSlot LATCH — the REGISTRY OBJECT is created once and its identity is then stable. A second
72
+ * write drops every host id recorded so far, so `hostIdentity()` answers "no host key in this
73
+ * process" for a process that demonstrably has one, and the kit injects its own `wdev_*` beside it
74
+ * without warning. Its CONTENTS are live (`host` gains an entry on every host-sourced
75
+ * `resolveIdentity`), so both accessors go through `provenanceRegistry()` on every call.
76
+ */
69
77
  const IDENTITY_PROVENANCE_SLOT: unique symbol = Symbol.for("@wireai/activation:identityProvenance");
70
78
 
71
79
  /** `"<space>:<scope>"` → the HOST-supplied value seen for it. Auto values are never recorded. */
@@ -31,7 +31,11 @@ export const resolveStorage = (
31
31
  declare const __DEV__: boolean | undefined;
32
32
 
33
33
  /** Once-per-process latch on its own `Symbol.for` slot: a plain `let` would warn once per inlined
34
- * bundle copy, and the gates live in three of them. */
34
+ * bundle copy, and the gates live in three of them.
35
+ *
36
+ * @globalSlot LATCH — written `true` by the first gate that found no sync storage, and never again.
37
+ * A second differing write re-arms a warning the process already spent, so a host that mounts three
38
+ * gates gets the same paragraph three times and stops reading it. */
35
39
  const NO_STORAGE_WARNED_SLOT: unique symbol = Symbol.for(
36
40
  "@wireai/activation:gateStorageWarned",
37
41
  );
@@ -138,6 +142,12 @@ const readStr = (storage: CoachmarkStorage | null, key: string): string | undefi
138
142
  // regardless of what the session registry does afterwards. The client counter only has to be
139
143
  // monotone and per-launch; the server's own `min_sessions` still counts real `app.session_started`
140
144
  // events, so nothing downstream needs the two ids to be identical.
145
+ //
146
+ // @globalSlot LATCH — pinned by the FIRST read and stable for the whole launch. This is the one
147
+ // slot whose latch is the fix: it samples the LIVE `currentSessionId` once, on purpose, so that
148
+ // registry changing underneath it (render before effect, on every cold start) cannot change the
149
+ // unit mid-launch and hand `minSessions: 2` a second "session" inside one app open. A second
150
+ // differing write is therefore not a stale-cache bug, it IS the defect — see the block above.
141
151
  const PROCESS_OPEN_ID_SLOT: unique symbol = Symbol.for("@wireai/activation:processOpenId");
142
152
 
143
153
  type GlobalWithOpenId = typeof globalThis & { [PROCESS_OPEN_ID_SLOT]?: string };
@@ -35,6 +35,13 @@ import type { OnboardingTheme } from "./types";
35
35
  * Well-known key into the runtime-global symbol registry. `Symbol.for` (NOT a plain `Symbol()`) is
36
36
  * what makes this cross-bundle: it returns the SAME symbol for the same string across every copy of
37
37
  * this module, so duplicated inlined copies all address one slot.
38
+ *
39
+ * @globalSlot LATCH — the React context object is minted by the first copy of this module to load
40
+ * and every later copy ADOPTS it (the `if (!…)` guard below says so in one line). A second write
41
+ * re-splits the identity: `<OnboardingThemeProvider>` (root barrel only) publishes into one context
42
+ * while `<ReviewGate>` from a subpath reads another, and `useOnboardingTheme`'s `ctx ?? mergeTheme()`
43
+ * fallback quietly serves the NEUTRAL theme. Because it is a latch, and only because of that, the
44
+ * module-local `const OnboardingThemeContext` below may cache it.
38
45
  */
39
46
  const THEME_CONTEXT_SLOT: unique symbol = Symbol.for("@wireai/activation:themeContext");
40
47