@wireai/activation 0.13.2 → 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 (82) hide show
  1. package/AGENTS.md +6 -3
  2. package/CHANGELOG.md +291 -4
  3. package/INTEGRATION_PROMPT.md +7 -1
  4. package/README.md +68 -2
  5. package/dist/analytics/index.d.mts +86 -6
  6. package/dist/analytics/index.d.ts +86 -6
  7. package/dist/analytics/index.js +271 -472
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +269 -471
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/coachmarks/index.d.mts +15 -2
  12. package/dist/coachmarks/index.d.ts +15 -2
  13. package/dist/coachmarks/index.js +58 -14
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +58 -14
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-ClkLjcJ0.d.mts → currentSession-DgJf0fRz.d.mts} +4 -4
  18. package/dist/{currentSession-DOVZEWJl.d.ts → currentSession-EOHU64QD.d.ts} +4 -4
  19. package/dist/{decision-Bl_M2y3r.d.mts → decision-Bgo17oH7.d.mts} +1 -1
  20. package/dist/{decision-Cau5KmP6.d.ts → decision-Bkh_LigV.d.ts} +1 -1
  21. package/dist/index.d.mts +27 -4
  22. package/dist/index.d.ts +27 -4
  23. package/dist/index.js +224 -575
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.mjs +221 -572
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/questionnaire/index.d.mts +43 -11
  28. package/dist/questionnaire/index.d.ts +43 -11
  29. package/dist/questionnaire/index.js +45 -13
  30. package/dist/questionnaire/index.js.map +1 -1
  31. package/dist/questionnaire/index.mjs +45 -13
  32. package/dist/questionnaire/index.mjs.map +1 -1
  33. package/dist/reviews/index.d.mts +5 -5
  34. package/dist/reviews/index.d.ts +5 -5
  35. package/dist/reviews/index.js +31 -9
  36. package/dist/reviews/index.js.map +1 -1
  37. package/dist/reviews/index.mjs +31 -9
  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 +7 -2
  42. package/dist/showcase/index.js.map +1 -1
  43. package/dist/showcase/index.mjs +7 -2
  44. package/dist/showcase/index.mjs.map +1 -1
  45. package/dist/{transport-CF_eHwzC.d.mts → transport-B_0SgCBe.d.ts} +44 -5
  46. package/dist/{transport-DsRe4epC.d.ts → transport-j5gFfJhK.d.mts} +44 -5
  47. package/dist/{types-CKFhyrMu.d.mts → types-BpwiRpA8.d.mts} +1 -1
  48. package/dist/{types-CKFhyrMu.d.ts → types-BpwiRpA8.d.ts} +1 -1
  49. package/dist/{types-CNUqMK0D.d.mts → types-Cju-1_jT.d.mts} +42 -2
  50. package/dist/{types-Buj9Lw9t.d.ts → types-h2BZvl1t.d.ts} +42 -2
  51. package/llms.txt +1 -1
  52. package/package.json +6 -1
  53. package/src/OnboardingFlow.tsx +44 -15
  54. package/src/WireOnboarding.tsx +7 -6
  55. package/src/activation/revalidation.ts +9 -1
  56. package/src/activation/wireActivation.ts +3 -3
  57. package/src/analytics/analyticsFacade.ts +2 -2
  58. package/src/analytics/currentSession.ts +10 -0
  59. package/src/analytics/eventQueue.ts +88 -20
  60. package/src/analytics/index.ts +6 -0
  61. package/src/analytics/reportClientEvent.ts +29 -7
  62. package/src/analytics/wireDoctor.ts +330 -0
  63. package/src/cards/CardGridSelectCard.tsx +1 -1
  64. package/src/cards/ChipSelectCard.tsx +2 -2
  65. package/src/cards/SelectionCard.tsx +1 -1
  66. package/src/coachmarks/runtime.ts +72 -3
  67. package/src/coachmarks/useCoachmarkTour.ts +62 -14
  68. package/src/context/deviceId.ts +14 -5
  69. package/src/features/WireFeaturesProvider.tsx +12 -1
  70. package/src/identity/identityRecord.ts +9 -1
  71. package/src/identity/userIdentity.ts +1 -1
  72. package/src/questionnaire/QuestionnaireGate.tsx +90 -10
  73. package/src/questionnaire/index.ts +9 -1
  74. package/src/questionnaire/transport.ts +46 -11
  75. package/src/reviews/ReviewGate.tsx +61 -6
  76. package/src/reviews/index.ts +10 -1
  77. package/src/reviews/runtime.ts +11 -1
  78. package/src/reviews/transport.ts +58 -12
  79. package/src/session-analytics/useLifecycleEvents.ts +1 -1
  80. package/src/theme/ThemeContext.tsx +49 -1
  81. package/src/types.ts +1 -1
  82. package/src/utils/submitResult.ts +39 -0
package/AGENTS.md CHANGED
@@ -32,7 +32,7 @@ Everything an integration needs lives on the ROOT import (`@wireai/activation`).
32
32
  subpaths are optional secondary feature modules; import one only if you use it.
33
33
 
34
34
  - `@wireai/activation` (root): `WireOnboarding`, the helpers/types below, and the device-only event reporters (`reportClientEvent`, `makeSessionId`, `WIRE_ONBOARDING_EVENTS`, see "App events" below).
35
- - `@wireai/activation/analytics`: the analytics FACADE and its queue: `createAnalytics`, `useAnalytics`, `createScreenTracker`, `reportAppEvent`, `createEventQueue`, `clearUserContext`. These are NOT root exports; importing them from the root does not compile.
35
+ - `@wireai/activation/analytics`: the analytics FACADE and its queue: `createAnalytics`, `useAnalytics`, `createScreenTracker`, `reportAppEvent`, `createEventQueue`, `clearUserContext`, `wireDoctor`. Except for `clearUserContext`, these are NOT root exports; importing them from the root does not compile. `clearUserContext` IS also a root export (`import { clearUserContext } from "@wireai/activation"`), which is the form the README teaches for the sign-out purge. `wireDoctor({ target, storage })` is the DEV-ONLY integration self-check: it resolves `{ok, checks:[{name, ok, detail}]}`, never throws, and posts one `dry_run` probe event so a broken integration is caught on the first run instead of in a funnel report. It is dev-gated on `__DEV__` (a release build makes no network call), it reports the apiKey's SHAPE and never its value, and its verdict is read from the ack body, never from the status code. See the README "Verify your integration" section.
36
36
  - `@wireai/activation/coachmarks`: in-app coachmark tour (`CoachmarkProvider`, `useCoachmarkTour`).
37
37
  - `@wireai/activation/showcase`: post-onboarding feature showcase (`FeatureShowcase`).
38
38
  - `@wireai/activation/reviews`: store-review gating (`ReviewGate`, `ReviewModal`, `useReviewGate`, `fetchReviewDecision`).
@@ -79,7 +79,9 @@ subpaths are optional secondary feature modules; import one only if you use it.
79
79
  | `storage` | `WireOnboardingStorage` | no | Host-injected AsyncStorage-compatible storage. When set, the kit caches its session id so an app kill mid-onboarding resumes the SAME backend session (keeps the `started` count honest). Persists the correlation seed only, never answers. |
80
80
  | `sessionTtlMs` | `number` | no | How long a persisted session id stays resumable, ms. Default `3600000` (1h). Only meaningful with `storage`. |
81
81
  | `persistKey` | `string` | no | Override the storage key (default `wireai:session:<config.appId>`). Scope it per-user if one device runs onboarding for multiple accounts. Only meaningful with `storage`. |
82
+ | `retainSessionOnComplete` | `boolean` | no | Opt IN: keep the persisted session seed alive ACROSS completion. A multi-stage or replay-after-complete re-entry within one signup (typically a `key=` remount) then resumes the SAME session instead of minting a fresh `metadata.sessionId`, which the backend adopts as a second `contextId` and double-counts as a `session_started`. Omitted or `false` clears the seed on completion, the legacy single-stage behavior, so leaving it unset changes nothing. When `true`, freshness comes from `sessionTtlMs` plus an explicit new-run signal (scope a new `persistKey`, e.g. a per-signup id). Only meaningful with `storage`. |
82
83
  | `userContext` | `Record<string, string \| number \| boolean>` | no | Host-injected, non-PII context (signup method, referral, plan tier, a hashed user id). Forwarded to the backend + client events for funnel segmentation. No raw PII. |
84
+ | `autoJoinKey` | `boolean` | no | Opt OUT of the automatic join key. Default `true`. When `userContext` carries no `device_key` and `storage` is present and actually persists (a storage adapter that throws or rejects is treated exactly like no `storage`), the kit injects its own per-install key, the same id `createAnalytics` / `createWireActivation` mint and persist, so the `activated` funnel joins with no host wiring. Pass `autoJoinKey={false}` for a genuinely UNLINKED session: that restores the pre-0.12.2 behavior and the dev warning fires again. It never overrides a `device_key` you passed, and it cannot inject without `storage`. |
83
85
  | `userId` | `string` | no | The host's OPAQUE PSEUDONYMOUS user id (not an email/name/phone), so onboarding sessions reconcile to real users. Late binding supported (mount, mid-session, or post-flow via `identifyOnboarding`). Capped at 128 chars. |
84
86
 
85
87
  ### `config` fields (`WireOnboardingConfig`)
@@ -100,7 +102,7 @@ FACADE (`createAnalytics` and friends) is a different surface and lives on `@wir
100
102
  - `WIRE_ONBOARDING_EVENTS` + `toAnalyticsEvent(event)`: canonical `wire_onboarding_*` funnel names; log via the app's own analytics.
101
103
  - `reportClientEvent(target, event)` / `reportClientEvents(target, events)`: report device-only funnel events. Contract: `POST {serverUrl}/v1/events`, header `Authorization: Bearer {apiKey}`, body `{ "events": [ ... ] }`. `target = { serverUrl, apiKey }` is derived from `WireOnboardingConfig`. Fire-and-forget: never throws into the UI.
102
104
  - `makeSessionId()`: a unique-per-onboarding session id (also seeds the A2A `contextId` so client and server events correlate).
103
- - `ClientEventType` is `"screen_skipped" | "dropped" | "client_fallback" | "identify"`. `<WireOnboarding>` auto-reports `dropped` (unmount without complete) and `client_fallback` (degrade to static). Hosts must NOT double-report fallback.
105
+ - `ClientEventType` is `"screen_skipped" | "dropped" | "client_fallback" | "identify" | "app_event"` (`app_event` is the type the whole `wire.track` / `reportAppEvent` surface rides on). `<WireOnboarding>` auto-reports `dropped` (unmount without complete) and `client_fallback` (degrade to static). Hosts must NOT double-report fallback.
104
106
 
105
107
  ## Integration steps (ordered)
106
108
 
@@ -117,7 +119,8 @@ FACADE (`createAnalytics` and friends) is a different surface and lives on `@wir
117
119
  ## Review firing (do not hand-roll the decision fetch)
118
120
 
119
121
  If you gate the review prompt on the server, call **`fetchReviewDecision(target, { deviceKey })`**
120
- from `@wireai/activation/reviews` and pass the result to `useReviewGate({ decision })`. Never
122
+ from `@wireai/activation/reviews` and pass the result to `useReviewGate({ config, decision })`.
123
+ `config` is REQUIRED (the hook reads `config.id`); `decision` alone does not compile. Never
121
124
  write your own fetch.
122
125
 
123
126
  - It returns the FULL `{ fire, reason, arm }` on 2xx, and `null` ONLY when the server is
package/CHANGELOG.md CHANGED
@@ -3,13 +3,300 @@
3
3
  All notable changes to `@wireai/activation` (formerly `wireai-onboarding`).
4
4
  Historical entries below the rename keep the old package name on purpose.
5
5
 
6
+ ## [0.13.5] - 2026-08-03
7
+
8
+ Two halves of one idea: stop finding out about a broken events integration from a funnel report.
9
+
10
+ `POST /v1/events` answers HTTP 200 for a batch it discards and reports the refusal only in the
11
+ response body, so a client/server contract drift breaks nothing anyone can see. It quietly flattens
12
+ a graph. That is exactly how a numeric `ts` threw away every `app_event` through 0.13.0. This
13
+ release adds a check on each side of that gap: one in CI, one on the developer's first run.
14
+
15
+ (0.13.4 is deliberately skipped.)
16
+
17
+ ### Added
18
+
19
+ - **`wireDoctor`, a dev-only integration self-check**, exported from `@wireai/activation/analytics`.
20
+ It resolves a structured report and never throws, whatever the target, the storage adapter or the
21
+ network does. Four independent checks, each a `{name, ok, detail}` unit:
22
+
23
+ 1. `target`: the `serverUrl` parses and an `apiKey` is present. It reports the key's SHAPE only,
24
+ its length and whether the prefix matches, and never any character of the key itself.
25
+ 2. `reachability`: `GET /v1/events/contract` answers, so the URL really is a Wire server.
26
+ 3. `storage`: a write / read / delete probe proves the offline queue can persist. The probe uses
27
+ its own key, outside the queue's namespace, and deletes it in a `finally` even when the read or
28
+ the write throws. A diagnostic that eats a pending backlog would be worse than no diagnostic.
29
+ 4. `round_trip`: one synthetic event goes through the REAL transport with `dry_run: true`, so the
30
+ server validates it exactly as it would a production event and writes nothing. The verdict is
31
+ read from the ack body (`written === 1 && skipped === 0`), never from the status code. A
32
+ `ts`-shaped drift surfaces as `skipped: 1` carrying the server's own `field: "ts"`.
33
+
34
+ Dev-only by contract: the `__DEV__` guard is the first statement, so a release build makes no
35
+ network call and writes no storage key. The report says it was skipped rather than reporting a
36
+ pass it never earned. No new export subpath, no new `globalThis` slot, and a host that never calls
37
+ it does not bundle it.
38
+
39
+ - **An `events-contract` CI job** that validates the payloads `buildEventsRequest` actually produces
40
+ against the JSON Schema the production server publishes, one fixture per send-path family
41
+ (analytics façade, session-start lifecycle, review transport, `client_fallback`, the unmount
42
+ `dropped`, and an offline queue flush carrying a persisted numeric-`ts` backlog). It uses the real
43
+ transport rather than a copied envelope, and reads the schema live rather than from a vendored
44
+ snapshot, so it cannot pass by validating a replica of something that has already drifted.
45
+
46
+ The fixtures are defined once, in `test/fixtures/sendPathFamilies.ts`, and read by both this check
47
+ and the transport-parity test. Each one names the emitter (`file:line`) whose shape it was built
48
+ from, and the check prints that line beside its result, so "this is what that path really sends"
49
+ is a claim you can check rather than one you have to trust. The module is under `test/` and is
50
+ absent from `files`, so it never reaches the tarball.
51
+
52
+ It fails, it never skips. A network error, a non-200, an unparseable contract, a missing
53
+ `client_event_types`, an unknown `event_type` or any invalid event exits non-zero. Run it locally
54
+ with `npm run check:contract`. `ajv` is a devDependency and does not ship in the tarball.
55
+
56
+ ### Changed
57
+
58
+ - **`buildEventsRequest` takes an optional third argument, `options?: { dryRun?: boolean }`.** The
59
+ `dry_run` key is added to the envelope ONLY on an explicit `true`, so every production send path
60
+ (which passes nothing) still serializes byte-identical bytes. That parity is asserted per
61
+ send-path family against golden strings captured from 0.13.3, because FIVE shipped call sites ride
62
+ this one envelope (the offline queue's `postBatch`, the fire-and-forget POST, the awaitable POST,
63
+ the review transport, and the session-start lifecycle) and a diagnostic has no business changing it
64
+ for them.
65
+ - **`readEventsAck` is exported from its module** so `wireDoctor` reuses the same ack reader the send
66
+ paths use. It is deliberately NOT added to the `./analytics` barrel: the module's surface widens,
67
+ the package's public API does not. The exports map still has exactly 8 keys.
68
+
69
+ ## [0.13.3] — 2026-08-02
70
+
71
+ The headline is a fix nobody could see from inside the kit: **on the DEFAULT configuration, a
72
+ backend that never answered left the user on the loading screen forever.** Everything else in this
73
+ release is the machinery that let a defect that severe stay invisible to a 638-test suite.
74
+
75
+ ### Fixed
76
+
77
+ - **A retry could be recorded as the user's FIRST ANSWER.** Caught pre-publish, and like the double
78
+ review row it was NEW in this release — the retry-budget fix below is what made it reachable. The
79
+ auto-start effect keyed the kickoff payload on `resumed`, a MOUNT-time prop, so a retry inside a
80
+ fresh run re-sent `startMessage`. The server records any non-empty message that arrives while a
81
+ question is pending as that question's answer (`if user_message and pending and not
82
+ _stop_requested(...)` → appends `{key, question, answer}` to `session["answers"]`). The trigger
83
+ is not an outage: the watchdog fires at `startTimeoutMs` (15s default) while a slow LLM is still
84
+ generating card 1, the server already has that card pending, and the retry lands as its answer.
85
+ The user's first onboarding answer silently becomes the literal kickoff string — and answer data
86
+ is the product. On 0.13.2 this was unreachable because no second kickoff could ever fire.
87
+
88
+ Only the first attempt of a fresh session now sends `startMessage`; a resumed mount and every
89
+ retry (auto, timeout-driven, and the "Try again" button) send the skip sentinel, which is safe in
90
+ both server states — with a pending question it skips one card recording nothing, with no pending
91
+ it is zeroed and behaves as a normal kickoff.
92
+
93
+ ⚠️ **The cost, stated rather than hidden: when the server DID hold card 1 pending, the retry now
94
+ SKIPS that card — the user loses one question.** That is deliberate. A skipped card emits
95
+ `screen_skipped` and is visible in the funnel; a kickoff string stored as an answer is invisible
96
+ and corrupts the data every downstream decision reads. Losing a question beats inventing one.
97
+
98
+ - **A lost ack wrote TWO review rows.** Caught pre-publish, and it was NEW in this release: reading
99
+ the submit response is what made it possible, so 0.13.2 could not double-post. `submitReview`
100
+ returned `false` for two materially different outcomes — *nothing reached the server* and *the
101
+ server answered non-2xx* — and `ReviewGate.postOnce` un-latches on `false`, so the abandonment net
102
+ re-posted the identical body. A gateway 502 that arrives AFTER the row commits therefore wrote a
103
+ second review row, double-counting `count` and corrupting `avg`: the exact corruption the one-row
104
+ latch exists to prevent, on the code path added to protect the data. There is no idempotency key
105
+ on the wire (`create_review` mints the row id; `CreateReviewRequest` has no id field), so the kit
106
+ cannot tell the two apart after the fact — it has to stop guessing.
107
+
108
+ `submitReview` now resolves **`ReviewSubmitResult`** — `"accepted"` / `"rejected"` / `"unsent"` —
109
+ instead of a boolean, and `postOnce` un-latches on `"unsent"` alone. A response of ANY status
110
+ proves the server was reached, and a row the server answered for is the server's problem. The
111
+ shape change is free: 0.13.3 was never published. Still non-throwing, still fired synchronously —
112
+ the caller never awaits.
113
+
114
+ ⚠️ **Honest residual: this is safer, not safe.** `"unsent"` is not proof the server never got the
115
+ row either — a connection dropped after the insert committed throws exactly like an offline
116
+ device, so the single in-process re-post can still duplicate in that narrow window. Closing it
117
+ needs a **client-minted idempotency key the server upserts on**, which is a server change and is
118
+ not something the kit can fake. Until then the kit prefers losing a row to inventing one: a lost
119
+ detractor is a gap in the data, a duplicated one is a lie in it.
120
+
121
+ - **The questionnaire had the same hole, and it was worse.** The review fix above landed and its
122
+ semantic twin got none of it. `submitQuestionnaireResponse` returned `void` over a body of
123
+ `void fetch(...).catch(() => {})`, so the response was never read at all, and
124
+ `QuestionnaireGate.finish` set `submittedRef`, fired `questionnaire_submitted` and showed the
125
+ thank-you unconditionally. `onResolved` → `markResolved` then writes the permanent
126
+ `wire_questionnaire_<id>_<ver>_seen` key. The questionnaire is the only surface that both burns a
127
+ permanent once-gate **and** had zero evidence of delivery: a user who answered offline lost their
128
+ answers and was never asked again. The CHANGELOG's own justification for the review fix applies
129
+ word for word.
130
+
131
+ `submitQuestionnaireResponse` now resolves **`QuestionnaireSubmitResult`** (the shared
132
+ `SubmitResult`: `"accepted"` / `"rejected"` / `"unsent"`), and the gate posts through a `postOnce`
133
+ latch that un-latches on `"unsent"` alone, with one in-process re-post when the gate unmounts.
134
+ Both surfaces now import the verdict from one place, so the retry rule cannot drift between them: a
135
+ server that ANSWERED, even non-2xx, is never retried, because the response id is server-minted and
136
+ the wire has no idempotency key. The signature change is free: 0.13.3 was never published. Still
137
+ non-throwing, still fired synchronously, the caller never awaits.
138
+
139
+ ⚠️ **Same honest ceiling as reviews:** recovery is in-process only. A run answered offline and
140
+ never recovered before the app is killed is still lost, and the host still writes the once-gate on
141
+ `onResolved`. Durable cross-launch retry needs new public API and is deliberately not in this
142
+ release.
143
+
144
+ - **The theme context was not shared across the built subpath bundles.** `OnboardingThemeContext`
145
+ was a plain `createContext(...)` and tsup inlines it into five bundles, so `dist/index.js`,
146
+ `dist/reviews/index.js`, `dist/questionnaire/index.js`, `dist/coachmarks/index.js` and
147
+ `dist/showcase/index.js` each carried their own instance. `OnboardingThemeProvider` is exported
148
+ only from the root barrel, so on any `dist` resolution path (plain RN CLI,
149
+ `unstable_enablePackageExports: false`, node, jest, SSR) a host that wrapped its tree in
150
+ `<OnboardingThemeProvider>` and rendered `<ReviewGate>` from `@wireai/activation/reviews` wrote
151
+ one context and read another. Nothing crashed: `useOnboardingTheme`'s `ctx ?? mergeTheme()`
152
+ fallback served the neutral default theme, so the tenant's brand silently did not apply. Metro
153
+ masked it on-device by collapsing every subpath back to one source file, the same bundler accident
154
+ behind the coachmark-runtime split and the optional-peers bug fixed above.
155
+
156
+ The context now lives on a `globalThis` slot keyed by
157
+ `Symbol.for("@wireai/activation:themeContext")`, exactly as `coachmarks/runtime`,
158
+ `analytics/currentSession`, `context/deviceId` and `features/WireFeaturesProvider` already did.
159
+ Measured in the built artifact, before and after: the root provider reached **0** of the four
160
+ subpath bundles' theme contexts before, and exactly **1 shared instance** after.
161
+ `test/canary/themeContextIdentity.test.tsx` loads the real bundles and pins it.
162
+
163
+ - **`WireFeaturesProvider` read `global` where the other nine symbol slots read `globalThis`.**
164
+ `global` does not exist in a plain browser, so that line was a `ReferenceError` waiting for the
165
+ first RN-web or browser-SSR host to mount the provider.
166
+
167
+ - **`maxRetries` never re-armed, so the fallback was unreachable on every host running the default.**
168
+ `maxRetries` defaults to `1`, and `1` and `2` behaved *identically*: exactly one retry fired, ever,
169
+ and no terminal state was ever reached. Measured before the fix — `0` → `{kickoffs:1, fallback:1}`,
170
+ `1` → `{kickoffs:1, fallback:0}`, `2` → `{kickoffs:1, fallback:0}`. Only `maxRetries={0}` reached
171
+ the fallback, which is the one value that trades the retry away entirely. A backend hung at start
172
+ therefore produced a 15s loader and then a permanent "Getting started…": no error, no fallback, no
173
+ way out. The "Try again" button was dead from the same cause, sending `1 → 1`.
174
+
175
+ Two causes, both required. `restartThread()` reset a **ref**, and a ref cannot re-trigger an
176
+ effect — the auto-start effect's deps held only stable values. And the startup watchdog keyed on
177
+ `lastCard`, which is still `undefined` after a retry, so no new timer ever armed and `timedOut`
178
+ could not become true a second time. Both effects now key on an incrementing `runId` held in
179
+ **state**, so a retry genuinely re-arms both; `started`/`resumed` deliberately do **not** re-fire,
180
+ so a retry cannot double-count a session in the host's funnel. After the fix, `maxRetries` means
181
+ what it says: `1` → `{kickoffs:2, fallback:1}`, `2` → `{kickoffs:3, fallback:1}`.
182
+
183
+ It survived a 638-test suite because the canary's `wireai-rn` mock hardcoded `error: undefined`,
184
+ leaving the entire error/retry/fallback state machine with **zero render coverage**. The mock
185
+ gained an error mode and the budget is now asserted for 0, 1 and 2 along with the timeout path
186
+ and the "Try again" button.
187
+
188
+ - **A slow cold-start storage read silently destroyed the persisted analytics backlog.** The
189
+ hydration read races a 1500 ms deadline, and the loser resolved to `undefined` — indistinguishable
190
+ from "the adapter answered, there is no backlog". Hydration took its empty early return, the
191
+ surrounding `catch` never ran, and the next `persist()` wrote the in-memory list over the blob
192
+ nobody had read; with nothing pending it deleted the blob outright. Up to `maxSize` = **200**
193
+ real events lost on nothing worse than a cold start where storage took two seconds, with no error,
194
+ no log and nothing to grep for. A timeout is now a distinct sentinel, persistence is suppressed
195
+ until the read settles, and the original read is still awaited — the late backlog is merged and
196
+ flushed rather than abandoned. No test injected a slow adapter; one does now.
197
+
198
+ - **A review rating was latched as posted before the request settled.** The once-gate was written
199
+ regardless, and the transport was a fire-and-forget `fetch` whose response was never read — so a
200
+ 1-4 star rating with mandatory free text travelled the kit's least durable path while
201
+ `analytics.track` got a persisted queue with backoff and six retries. A detractor who rated
202
+ offline was dropped *and* never asked again. `submitReview` now reports delivery, the latch is
203
+ released when the row did not land, and the undelivered body is re-posted by the existing
204
+ abandonment net. ⚠️ Recovery is in-process only: a rating taken offline and then killed with the
205
+ app is still lost. A persisted review outbox is deliberately left for a later release.
206
+
207
+ - **The coachmarks kill switch could leave a tour unable to finish.** The flag is a mutable global
208
+ read once with no subscription: the arm effect omitted it from its deps and the drive effect never
209
+ read it, so flipping it mid-tour made `show()` a silent no-op and left `finish()`/`onComplete`
210
+ unreachable. It is now subscribable and read through `useSyncExternalStore` in both effects, and a
211
+ mid-tour disable exits terminally **without** writing the once-gate, so the tour is not consumed.
212
+
213
+ - **Four of the six optional peers were missing from the published bundle entirely.** Measured with
214
+ Metro's own dependency collector, the 0.13.2 tarball's `dist/` collected **2 of 6**
215
+ `OPTIONAL_MODULES` while the same collector over `src/` collected **6 of 6** — so on every
216
+ resolution path that lands on `dist/` (plain RN CLI, `unstable_enablePackageExports: false`, node,
217
+ jest) `@expo/vector-icons`, `expo-constants`, `expo-application` and `expo-device` were not in the
218
+ bundle at all: `WireIcon` renders null and `by_model` / `by_app_version` stay empty, with the
219
+ guard's own `catch` hiding it. Source consumers (both monorepo apps, and anything else taking
220
+ the `react-native` condition) were never affected, which is why a green suite and two shipping
221
+ apps said nothing. Two independent build-config faults, both silent at exit 0 with zero warnings:
222
+ - `tsup.config.ts` `external` was a hand-maintained list covering 2 of the 6. The consequence
223
+ depended on the BUILD MACHINE: a module that happened to resolve in the kit's own
224
+ `node_modules` (`expo-constants`, pulled in transitively) was **inlined whole** into the bundle,
225
+ so the host's copy went unused; one that did not resolve was left as an orphan call. `external`
226
+ is now **derived from `metro/index.js`'s `OPTIONAL_MODULES`** — the same list the Metro helper
227
+ already treats as the source of truth — plus the statically-imported peers, with a build-time
228
+ throw if the two ever diverge. The output no longer depends on what is installed here.
229
+ - Marking them `external` was necessary and **not sufficient**. `treeshake: true` is not an
230
+ esbuild flag; tsup implements it as a Rollup pass whose ESM output is then re-derived into CJS,
231
+ and in that conversion esbuild renames `require("expo-device")` to `__require("expo-device")`,
232
+ external or not. Metro's collector matches only a callee literally named `require`, so a
233
+ renamed call is collected nowhere — the 0.8.0 bug, reintroduced by a build flag rather than by
234
+ source. The build now carries a small `renderChunk` hook that restores the literal `require`
235
+ for exactly the `OPTIONAL_MODULES` specifiers, and only in CJS output, where the rewrite is an
236
+ identity (esbuild defines `__require` as `typeof require !== "undefined" ? require : …`, so in
237
+ a CJS chunk it already *is* `require`). `treeshake` stays on and every other byte of the bundle
238
+ keeps its current shape — `dist/index.js` is 218.6 KB, down from 232.2 KB purely because
239
+ `expo-constants` is no longer inlined. `dist/*.mjs` keeps `__require`: ESM has no `require` to
240
+ preserve, and no React Native path reads it (`react-native` → `src/`, `main` / the `require`
241
+ condition → `dist/*.js`).
242
+
243
+ `test/canary/metroResolution.test.tsx` ran Metro's collector over `src/` only, which is how a
244
+ publish-surface defect stayed invisible to the test that owns exactly this property. It now runs
245
+ the same assertions over the BUILT `dist/` (build-if-stale, never skipped), with its own negative
246
+ control pinning that a `__require`-shaped call collects nothing. Verified against the pre-fix
247
+ config: the new assertion goes red and names all four missing modules.
248
+
249
+ - **The banned-name canary now actually gates a publish.** It was documented as a "build-time canary
250
+ that fails the pack" and it was neither: `prepack` was `npm run build` and ran no test at all, so a
251
+ planted banned name packed at **exit 0**, inside the tarball. The second half compounded it
252
+ — the canary derives its scan set from `package.json` `files`, which includes `dist`, but `dist` is
253
+ gitignored and sits in the scanner's `OPTIONAL_WHEN_ABSENT` list, so on an unbuilt tree the suite
254
+ was **6/6 green while covering none of the built output that ships**. Fixing either half alone
255
+ still ships a name under some ordering. The gate is now one script that closes both:
256
+ `prepublishOnly` = `npm run build && npm run test:canary`. It fires on `npm publish`, and it fires
257
+ *after* the build, so `dist/` is on disk and inside the scan. `test:canary` is a new script that
258
+ runs `test/canary/**` on its own, and `bannedNames.test.tsx` gained a test that asserts this wiring
259
+ from inside the suite, so deleting the gate goes red instead of silently reopening the hole.
260
+ ⚠️ `npm pack` on its own still does not run the canary — the honest verb is *publish*, not *pack*.
261
+
262
+ ### Changed
263
+
264
+ - **Test integrity.** `npm test` now builds first (`pretest`), because the new `dist/` assertions
265
+ shell out to a build with `clean: true` while other test files walk `dist/` in parallel — that
266
+ race could redden CI for a reason unrelated to any change. Separately, the pre-existing
267
+ `DE-DUP: re-enqueuing an identical pending event is collapsed` test was **flaky on `main`**: it
268
+ fires three synchronous enqueues against a live clock while the queue stamps `ts` into the de-dup
269
+ signature, so a millisecond boundary between them makes the third a genuine repeat by the queue's
270
+ own rules. Measured at 0.32% of runs on an untouched `main`, 0/6000 with the clock frozen; it is
271
+ now pinned exactly as its three sibling tests already were. And `mergePersisted`'s de-dup branch
272
+ had **zero coverage** — it could be deleted with the whole suite green — which matters precisely
273
+ because the backlog fix above newly merges a late read into a pending list that may already hold
274
+ the same event. It is covered now.
275
+
276
+ - **Four guards this release relies on had no test at all**, found by mutating the suite. Each one
277
+ was a promise made in writing and enforced by nothing, so each could be deleted with a fully green
278
+ run: (1) `started`/`resumed` not re-firing on a retry — stated right here as *"a retry cannot
279
+ double-count a session"*, and in tension with the `runId` re-arm by construction, so a host funnel
280
+ would have read one flaky cold start as three sessions; (2) `pretest` itself, whose removal
281
+ silently reopens the `clean: true` race described above; (3) both build-time throws in
282
+ `tsup.config.ts`, which are the only thing standing between a refactor of the `external`
283
+ derivation and a bundle that silently inlines or orphans every guarded optional peer at exit 0;
284
+ and (4) the dist half of `metroResolution`'s HARD-dependency check, which its own
285
+ `.filter((m) => !collected.has(m))` had rendered unfailable — a module collected optional in one
286
+ chunk excused a hard require of it in another, and Metro resolves per call site, not per package.
287
+
6
288
  ## [0.13.2] — 2026-07-31
7
289
 
8
290
  The headline is a fix: every `track()` / `screen()` `app_event` was being silently discarded by the
9
291
  server behind an HTTP 200, on every kit version since 0.11.0. This release also ships the injectable
10
292
  mid-flow permission screens below, and it is the FIRST version whose published tarball is clean of
11
- the banned client name — the 0.13.0 publish-surface scrub now rides a build-time canary that fails
12
- the pack if the name ever reappears.
293
+ the banned client name — the 0.13.0 publish-surface scrub rides a canary that fails the run if the
294
+ name ever reappears.
295
+
296
+ > ⚠️ **Corrected 2026-08-02.** This paragraph originally said the canary "fails the pack". It did
297
+ > not: at 0.13.2 nothing ran it on `npm pack` or `npm publish`, and on an unbuilt tree it did not
298
+ > scan `dist/` either. The 0.13.2 tarball is still clean — that was verified by hand — but it was
299
+ > clean without a gate holding it. See the 0.13.3 entry above for the fix.
13
300
 
14
301
  ### Fixed
15
302
 
@@ -146,7 +433,7 @@ direction: the kit now declines and says so, where it used to fabricate and retu
146
433
  the question's prompt text (tenant flows slugify it and cut at 32 chars), so re-wording a question mints
147
434
  a NEW key and a host reading `answers.interests` silently starts reading `undefined`.
148
435
  ⚠️ ~~**INERT UNTIL THE SERVER EMITS IT.** No deployed server sends `slot_id` today.~~
149
- ✅ **CORRECTED 2026-07-28: this is LIVE.** The deployed server (`47dae92`) sends `slot_id` on
436
+ ✅ **CORRECTED 2026-07-28: this is LIVE.** The deployed server sends `slot_id` on
150
437
  `progress` for every AI-generated question, as `adaptive_<n>`, and for a configured question
151
438
  whenever the tenant set one. Where no slot is configured the field is absent and
152
439
  every path degrades to byte-identical 0.12.2 behaviour. Every fallback is decided
@@ -499,7 +786,7 @@ Architecture-audit fixes (#50/#51). One BEHAVIOR CHANGE, called out below.
499
786
  store-declaration implications). The blanket "No PII / nothing identifies a device" claims are
500
787
  scoped to the onboarding `userContext` prop + the `collectDeviceContext()` snapshot. `llms.txt`
501
788
  now lists the `/analytics`, `/reviews`, `/questionnaire` subpath surfaces.
502
- - Added the `session/sessionIdentity.integration.test.ts` dist-canary (PR #47/#48).
789
+ - Added the `session/sessionIdentity.integration.test.ts` dist-canary.
503
790
 
504
791
  ## [0.10.0] - 2026-07-20
505
792
 
@@ -76,7 +76,13 @@ STEPS (do them in order, stop and ask if a convention is ambiguous):
76
76
  onboarding on `user_context.device_key` and never on `session_id` (those are separate id spaces
77
77
  and intersecting them returns zero rows). If my app has no single stable device key yet, say so
78
78
  instead of inventing one.
79
- 11. Verify: type-check (and lint the changed files); test the flag-off + backend-error paths.
79
+ 11. Verify: type-check (and lint the changed files); test the flag-off + backend-error paths. Then
80
+ prove the events wiring actually works, because `/v1/events` answers 200 for a batch it discards:
81
+ behind an `if (__DEV__)` guard, call
82
+ `wireDoctor({ target: { serverUrl: WIREAI_SERVER_URL, apiKey: WIREAI_API_KEY }, storage })` from
83
+ `@wireai/activation/analytics` once at app root and log the report. Every check must come back
84
+ `ok`. A failing `round_trip` names the field the server refused. Remove the call, or leave it
85
+ dev-gated, once the report is green.
80
86
 
81
87
  Report back: the files you changed (path:line), the typecheck result, and anything you couldn't
82
88
  infer about my conventions.
package/README.md CHANGED
@@ -234,10 +234,13 @@ import { WireOnboarding } from "@wireai/activation";
234
234
  | `onComplete` | `(result: { answers, raw }) => void` | Fires when the user taps the terminal recap's CTA. **Required.** |
235
235
  | `theme` | `Partial<OnboardingTheme>` | Brand colors/fonts/radius/spacing, deep-merged over a neutral default. |
236
236
  | `illustrations` | `Record<string, ReactNode>` | App artwork for `InterstitialCard`, keyed by name. |
237
+ | `icons` | `Record<string, ReactNode>` | Your own icon nodes, keyed by the semantic name the AI emits (`{ instagram: <BrandIg/> }`). Checked first, so use it to put your brand mark on a choice card, to add names the vocabulary does not carry, or to get icons at all without installing `@expo/vector-icons`. Anything you leave out falls back to that optional peer, then to no icon. Never a crash. See [Icons](#icons). |
237
238
  | `validators` | `Record<string, StepValidator>` | Per-step, keyed by base-question key (e.g. `username`). Blocks advance + shows an inline error. When the backend sends a `progress.slot_id` for a screen, the kit looks up that slot FIRST and falls back to the question key, so a validator map written against today's keys keeps working. |
238
239
  | `permissionScreens` | `PermissionScreenConfig[]` | Priming screens injected mid-flow, starting with notifications. The screen explains why, and the OS dialog opens **only** on the primary tap, never on mount. Zero new dependencies: you inject `request`. See [Permission screens](#permission-screens-the-priming-pattern). |
239
240
  | `onSkip` | `() => void` | User skipped. |
240
- | `onError` | `(err) => void` | Backend error/timeout host owns recovery (e.g. route to a static flow). Without it, the kit shows an inline retry. |
241
+ | `onError` | `(err) => void` | Backend error or first-card timeout, fired **after retries are exhausted**, not on the first failure. When `fallbackFlow` is not supplied, the host owns recovery (e.g. route to a static flow). With neither, the kit shows an inline retry. `fallbackFlow` takes precedence over this. |
242
+ | `fallbackFlow` | `ReactNode` | Your predefined STATIC onboarding, rendered in place once the AI flow fails and retries are exhausted. **Takes precedence over `onError`.** This is the "it can never break your onboarding" guarantee: pass the same flow you shipped before adding Wire AI and the user always keeps moving. |
243
+ | `maxRetries` | `number` | Consecutive failures to auto-retry before degrading to `fallbackFlow`/`onError`. Default `1` (one silent retry, then degrade). `0` degrades on the first failure. Budget it deliberately: each retry restarts the flow, so with `startTimeoutMs` at its `15000` default the default `maxRetries` puts 30s of loader in front of a user before they see the fallback. A retry also sends a skip sentinel, so the user loses one question and a retry is never recorded as their first answer. |
241
244
  | `onEvent` | `(e: OnboardingEvent) => void` | `started` / `turn` / `error` — recover per-turn analytics since the kit owns the loop. |
242
245
  | `copy` | `Partial<OnboardingCopy>` | Localize the kit's built-in English loader/completion strings. |
243
246
  | `approxScreens` | `number` | Approx total screens — paces the bar as `step/total` (capped, never shown). A backend `progress.total` (the screen budget, sent every turn incl. init) wins, so usually unnecessary. |
@@ -247,6 +250,7 @@ import { WireOnboarding } from "@wireai/activation";
247
250
  | `storage` | `WireOnboardingStorage` | Host-injected storage (AsyncStorage-compatible `getItem/setItem/removeItem`) for **session-id persistence**: an app KILL mid-onboarding resumes the SAME backend session instead of minting a new one, so the funnel's `started` count stays honest (no phantom drops). Pass AsyncStorage as-is, or a 3-line MMKV wrapper. Omit for the previous per-mount behavior. Persists the kit's own correlation seed only — answers stay the host's job via `onComplete`. |
248
251
  | `sessionTtlMs` | `number` | How long a persisted session id stays resumable. Default `3600000` (1h, the backend's session TTL). With `storage` only. |
249
252
  | `persistKey` | `string` | Override the storage key (default `wireai:session:<appId>`). Scope per-user if one device can onboard multiple accounts mid-flow. With `storage` only. |
253
+ | `retainSessionOnComplete` | `boolean` | Opt IN: keep the persisted session seed alive across completion. A multi-stage signup that re-enters onboarding (typically a `key=` remount) then resumes the SAME session instead of minting a fresh `metadata.sessionId`, which the backend adopts as a second `contextId` and double-counts as a `session_started`. Omitted or `false` clears the seed on completion, the legacy single-stage behavior, so leaving it unset changes nothing. With `true`, freshness comes from `sessionTtlMs` plus an explicit new-run signal (scope a new `persistKey`, e.g. a per-signup id). With `storage` only. |
250
254
  | `userContext` | `Record<string, string \| number \| boolean>` | **This is where the join key goes.** `user_context.device_key` is the only thing that joins this onboarding session to the app's later events, so pass `userContext={activationJoinContext(deviceKey)}` (or `activationJoinContext(resolveAutoDeviceKey({ appId, storage }))` if your app owns no device id). Since 0.12.2, omitting it no longer empties the funnel silently: with `storage`, the kit injects its own key (see [The join key](#the-join-key-device_key-never-session_id) and `autoJoinKey` below). Second job: any other non-PII context the app already knows (signup method, referral, plan, a HASHED user id), forwarded on session metadata + client events for funnel segmentation. **No PII on THIS prop** (no raw emails/names); primitives only; the server caps size/keys. See [Device & user context](#device--user-context). ⚠️ Distinct from the analytics/activation **`WireUserContext`** object (`createAnalytics` / `useWireActivation`), which additionally accepts an opt-in raw `userEmail` — see [Rich user context & PII](#rich-user-context--pii). |
251
255
  | `autoJoinKey` | `boolean` | Opt OUT of the automatic join key. Default `true`. When this prop is left alone, `storage` is present **and actually persists** (0.13.0: a `storage` adapter that rejects or throws is treated exactly like no `storage` — the kit declines and says so, because a key it cannot persist is a different key on every launch, which corrupts `min_sessions` rather than merely leaving the join empty), and `userContext` carries no `device_key`, the kit injects its own per-install key (the same id `createAnalytics` / `createWireActivation` mint and persist), so the `activated` funnel joins with no host wiring. Pass `autoJoinKey={false}` if you genuinely want an UNLINKED onboarding session: that restores the pre-0.12.2 behavior exactly, and the dev warning fires again. It never overrides a `device_key` you passed, and it cannot inject without `storage` (see [The join key](#the-join-key-device_key-never-session_id)). |
252
256
  | `userId` | `string` | Your own OPAQUE user id, so onboarding sessions reconcile to real users later (console sessions to your user table / GA4 users). Optional and supports **late binding**: present at mount it rides the session-start metadata; if it changes mid-session (the user just registered) the kit emits an `identify` event; available only after the flow, use `identifyOnboarding(...)`. **No PII** (not an email/name/phone); trimmed and capped at 128 chars. See [User identity](#user-identity). |
@@ -712,6 +716,56 @@ check, because a component name a device cannot render does **not** degrade grac
712
716
  `COMPONENT_NOT_FOUND` inside `wireai-rn` before the renderer ever sees it, and the user loses the
713
717
  turn to the retry/fallback path.
714
718
 
719
+ ## Verify your integration (`wireDoctor`)
720
+
721
+ `POST /v1/events` answers **HTTP 200 for a batch it throws away**. It reports the refusal in the
722
+ response body (`{written, skipped, errors: [{reason, field}]}`), never in the status code. So a
723
+ mis-wired integration looks healthy from the outside while every event disappears. That is how a
724
+ numeric `ts` discarded every `app_event` through 0.13.0.
725
+
726
+ `wireDoctor` catches that on the first run instead of in a funnel report three weeks later.
727
+
728
+ ```tsx
729
+ import { useEffect } from "react";
730
+ import { wireDoctor } from "@wireai/activation/analytics";
731
+ import AsyncStorage from "@react-native-async-storage/async-storage";
732
+
733
+ useEffect(() => {
734
+ if (!__DEV__) return;
735
+ wireDoctor({
736
+ target: { serverUrl: DRIVELINE_SERVER_URL, apiKey: DRIVELINE_API_KEY },
737
+ storage: AsyncStorage,
738
+ }).then((report) => console.log(report.ok, report.checks));
739
+ }, []);
740
+ ```
741
+
742
+ It resolves `{ ok, checks: [{ name, ok, detail }] }` and **never throws**, whatever the target, the
743
+ storage adapter or the network does. Four independent checks:
744
+
745
+ | `name` | What it proves |
746
+ |---|---|
747
+ | `target` | `serverUrl` is a valid http(s) URL and an `apiKey` is present. Reports the key's SHAPE only (its length, and whether the prefix matches), never its value. |
748
+ | `reachability` | `GET /v1/events/contract` answers, so the URL really points at a Wire server. |
749
+ | `storage` | The offline queue can persist, via a write / read / delete probe on its own key. Your pending backlog is never read, overwritten or deleted. |
750
+ | `round_trip` | One synthetic event survives real server validation. It goes out with `dry_run`, so nothing is written and no metric moves. The verdict is `written === 1 && skipped === 0`, read from the ack body, because the status code cannot tell you. |
751
+
752
+ A `ts`-shaped drift shows up as `skipped=1` carrying the server's own `field: ts`.
753
+
754
+ **Dev-only by contract.** The `__DEV__` guard is the first statement in the function, so a release
755
+ build does nothing at all: no network call, no storage write. In that case the report says it was
756
+ skipped rather than reporting a pass it never earned. A host that never imports `wireDoctor` does not
757
+ bundle it.
758
+
759
+ ### The contract check
760
+
761
+ The kit's `events-contract` CI job validates the payloads `buildEventsRequest` actually produces
762
+ against the JSON Schema the production server publishes at
763
+ `https://wire-rn-dynamic-onboarding.fly.dev/v1/events/contract`, one fixture per send path. Run it
764
+ yourself with `npm run check:contract`.
765
+
766
+ It fails, it never skips. An unreachable endpoint, a non-200 or an unparseable contract is a red
767
+ job, because a check that has learned nothing must not report green.
768
+
715
769
  ## Consuming from source (Metro + tsconfig)
716
770
 
717
771
  The monorepo apps resolve the kit from source. In each app:
@@ -914,6 +968,18 @@ the "personalize from context" thesis. The once-gate
914
968
  It reads the active `OnboardingThemeProvider` theme; pass `theme` to override just
915
969
  the showcase, or `storage` / `isTesting` to gate independently of the provider.
916
970
 
971
+ Until 0.13.3 that sentence only held on a phone. `OnboardingThemeProvider` ships from
972
+ the root barrel, every subpath builds as its own bundle, and each of those bundles
973
+ carried a private copy of the theme context. Metro hid it: its `react-native` export
974
+ condition collapses all five subpaths back to one source file, so on-device the copies
975
+ were the same object by accident. Everywhere else (plain RN CLI,
976
+ `unstable_enablePackageExports: false`, node, jest, SSR) the provider wrote one context
977
+ while the gate read another, and `useOnboardingTheme` quietly falls back to the neutral
978
+ default theme instead of crashing. No error, no warning, wrong brand colors. The context
979
+ now lives in one `Symbol.for` slot on `globalThis`, next to the feature flags and the
980
+ coachmark runtime that already sat there, so a single provider themes every surface on
981
+ every resolution path.
982
+
917
983
  **v2 server seam (future, not built).** The onboarding server's final turn will
918
984
  later return an ordered `showcase: string[]` slide-key list chosen server-side from
919
985
  the same answers; the host passes it straight in as the `selection` argument.
@@ -1293,7 +1359,7 @@ Least privilege: for the read-only loop, hand the agent just `WIREAI_SERVER_URL`
1293
1359
  A pure-Markdown Claude skill for *using* Wire AI easily: install the kit + wire the
1294
1360
  `WireOnboarding` provider, read your insights (via the MCP or `/v1/insights.md`), and run
1295
1361
  the improve loop (read insights, then adjust the app description / first question /
1296
- generation context). It also carries the `BOILERPLATE20` credit note. For the full
1362
+ generation context). For the full
1297
1363
  scaffold-the-screen integration job, the [`wire-rn-integration`](./INTEGRATION_PROMPT.md)
1298
1364
  skill drives it end to end.
1299
1365
 
@@ -1,10 +1,10 @@
1
- export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-CF_eHwzC.mjs';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-ClkLjcJ0.mjs';
3
- export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, C as ClearUserContextOptions, b as ClientEvent, c as ClientEventTarget, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-ClkLjcJ0.mjs';
4
- import '../types-CNUqMK0D.mjs';
1
+ export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-j5gFfJhK.mjs';
2
+ import { C as ClientEventTarget, W as WireUserContext, E as EventQueueOptions } from '../currentSession-DgJf0fRz.mjs';
3
+ export { A as AUTO_DEVICE_ID_PREFIX, a as AnalyticsEvent, b as ClearUserContextOptions, c as ClientEvent, d as ClientEventType, e as ContextEnvelope, f as ContextEnvelopeInput, D as DeviceKeyStorage, g as EnvelopeSource, h as EventQueue, R as ResolveAutoDeviceKeyOptions, i as WIRE_ONBOARDING_EVENTS, j as WireOnboardingEventName, k as analyticsUserIdStorageKey, l as buildContextEnvelope, m as clearPiiFromContext, n as clearUserContext, o as createEventQueue, p as deviceIdStorageKey, q as ensureCurrentSessionId, r as getCurrentSessionId, s as looksLikeEmail, t as makeSessionId, u as reportClientEvent, v as reportClientEventAwait, w as reportClientEvents, x as reportClientEventsAwait, y as resetAutoDeviceKeys, z as resetCurrentSessionId, B as resetEventQueueKeys, F as resolveAutoDeviceKey, G as setCurrentSessionId, H as toAnalyticsEvent } from '../currentSession-DgJf0fRz.mjs';
4
+ import { W as WireOnboardingStorage } from '../types-BpwiRpA8.mjs';
5
+ import '../types-Cju-1_jT.mjs';
5
6
  import '../types-BKfpdZzX.mjs';
6
7
  import '../types-BcmagF6K.mjs';
7
- import '../types-CKFhyrMu.mjs';
8
8
  import 'react';
9
9
  import 'wireai-rn';
10
10
  import 'react-native';
@@ -95,6 +95,86 @@ interface NavigationRefLike {
95
95
  */
96
96
  declare const useScreenTracking: (navigationRef: NavigationRefLike | undefined, options?: ScreenTrackerOptions) => void;
97
97
 
98
+ /**
99
+ * wireDoctor: a DEV-ONLY, opt-in self-check that proves a fresh integration end to end before the
100
+ * first real user ever runs it.
101
+ *
102
+ * WHY IT EXISTS: the `/v1/events` endpoint answers HTTP **200** for a batch it throws away. It
103
+ * reports the refusal in the response body (`{written, skipped, errors:[{reason, field}]}`), so a
104
+ * mis-wired integration looks perfectly healthy from the outside while every event evaporates. A
105
+ * numeric `ts` did exactly that through 0.13.0. This turns that class of loss from something you
106
+ * discover in a funnel report weeks later into something the first run tells you.
107
+ *
108
+ * SHAPE: four independent checks, each a `{name, ok, detail}` unit that can be read (and tested)
109
+ * without the others. The report is data, never a thrown error and never a side effect on the host:
110
+ *
111
+ * 1. `target` : is there a server URL and a key, and do they look like a key and a URL?
112
+ * 2. `reachability`: is the server actually there? (`GET /v1/events/contract`, public and cheap)
113
+ * 3. `storage` : can the offline queue persist? (a write / read / delete probe)
114
+ * 4. `round_trip` : does a REAL event survive REAL server validation? (a `dry_run` POST)
115
+ *
116
+ * NEVER THROWS, under any input, any network condition, or any hostile response object. A doctor
117
+ * that can crash the screen it is diagnosing is worse than no doctor.
118
+ *
119
+ * ⛔ NEVER PRINTS A SECRET. The `target` check reports the key's SHAPE (present / absent, length,
120
+ * whether the prefix is the expected one) and never any character of the key itself, in any
121
+ * `detail`, any log line, or any error. `wireDoctor.test.ts` asserts that with a sentinel key.
122
+ *
123
+ * COSTS A NON-CALLER NOTHING: it is a plain function behind the `./analytics` subpath, the package
124
+ * is `sideEffects: false`, and this module has no top-level side effects, so a host that never
125
+ * imports it never bundles it. It also mints no `globalThis` slot and retains no timer or listener.
126
+ *
127
+ * USAGE (dev builds only):
128
+ *
129
+ * import { wireDoctor } from "@wireai/activation/analytics";
130
+ *
131
+ * const report = await wireDoctor({
132
+ * target: { serverUrl: "https://api.example.com", apiKey: DRIVELINE_KEY },
133
+ * storage: AsyncStorage,
134
+ * });
135
+ * console.log(report.ok, report.checks);
136
+ */
137
+
138
+ /** One diagnosis. `name` is stable and machine-readable; `detail` is for a human reading a console. */
139
+ type WireDoctorCheck = {
140
+ /**
141
+ * Stable id: `target` | `reachability` | `storage` | `round_trip` | `dev_only` | `internal_error`.
142
+ *
143
+ * `dev_only` means ONE thing and only that thing: `__DEV__` is unset or false, so nothing ran.
144
+ * `internal_error` is the separate catch-all for a failure that got past every check's own
145
+ * swallow. They are distinct names because a consumer branching on `dev_only` would otherwise
146
+ * read an internal fault as "this is a release build" and report a healthy skip.
147
+ */
148
+ name: string;
149
+ ok: boolean;
150
+ /** Human-readable result. ⛔ Never contains any part of an API key. */
151
+ detail: string;
152
+ };
153
+ /** What {@link wireDoctor} resolves to. `ok` is true only when EVERY check passed. */
154
+ type WireDoctorReport = {
155
+ ok: boolean;
156
+ checks: WireDoctorCheck[];
157
+ };
158
+ /** Input for {@link wireDoctor}. `storage` is optional: without it the queue runs in-memory only. */
159
+ type WireDoctorOptions = {
160
+ /** The same `{serverUrl, apiKey}` the kit is configured with. */
161
+ target: ClientEventTarget | undefined;
162
+ /**
163
+ * The host storage the offline queue would use (AsyncStorage-compatible). Omit it and the storage
164
+ * check reports the DEGRADED in-memory mode rather than failing.
165
+ */
166
+ storage?: WireOnboardingStorage;
167
+ };
168
+ /**
169
+ * Run the full diagnosis. Resolves a report; NEVER throws and NEVER rejects.
170
+ *
171
+ * DEV-ONLY BY CONTRACT: the `__DEV__` guard is the FIRST statement, so a release build performs no
172
+ * network call, no storage write, and no work at all. The report says it was skipped rather than
173
+ * pretending everything passed, because a green report that never ran is the exact failure mode
174
+ * this whole feature exists to remove.
175
+ */
176
+ declare const wireDoctor: (options: WireDoctorOptions) => Promise<WireDoctorReport>;
177
+
98
178
  /** Arbitrary non-PII event properties. Serialized to the event's `meta` (a JSON string) on the wire. */
99
179
  type AnalyticsProps = Record<string, unknown>;
100
180
  /**
@@ -203,4 +283,4 @@ declare const createAnalytics: (config: CreateAnalyticsConfig, options?: Analyti
203
283
  */
204
284
  declare const useAnalytics: (config: CreateAnalyticsConfig, options?: AnalyticsOptions) => Analytics;
205
285
 
206
- export { type Analytics, type AnalyticsOptions, type AnalyticsProps, type CreateAnalyticsConfig, EventQueueOptions, type NavigationRefLike, type NavigationRouteLike, type NavigationStateLike, type ScreenTracker, type ScreenTrackerOptions, createAnalytics, createScreenTracker, getActiveRouteName, screenTrackingHandler, useAnalytics, useScreenTracking };
286
+ export { type Analytics, type AnalyticsOptions, type AnalyticsProps, ClientEventTarget, type CreateAnalyticsConfig, EventQueueOptions, type NavigationRefLike, type NavigationRouteLike, type NavigationStateLike, type ScreenTracker, type ScreenTrackerOptions, type WireDoctorCheck, type WireDoctorOptions, type WireDoctorReport, createAnalytics, createScreenTracker, getActiveRouteName, screenTrackingHandler, useAnalytics, useScreenTracking, wireDoctor };