@wireai/activation 0.12.2 → 0.13.2

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 (55) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +259 -1
  3. package/README.md +87 -3
  4. package/dist/analytics/index.d.mts +2 -2
  5. package/dist/analytics/index.d.ts +2 -2
  6. package/dist/analytics/index.js +174 -36
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +174 -37
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-ClkLjcJ0.d.mts} +456 -19
  11. package/dist/{currentSession-BxEB37xt.d.ts → currentSession-DOVZEWJl.d.ts} +456 -19
  12. package/dist/index.d.mts +197 -16
  13. package/dist/index.d.ts +197 -16
  14. package/dist/index.js +1056 -390
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +836 -193
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/questionnaire/index.js.map +1 -1
  19. package/dist/questionnaire/index.mjs.map +1 -1
  20. package/dist/reviews/index.js +20 -7
  21. package/dist/reviews/index.js.map +1 -1
  22. package/dist/reviews/index.mjs +20 -7
  23. package/dist/reviews/index.mjs.map +1 -1
  24. package/package.json +1 -1
  25. package/src/OnboardingFlow.tsx +141 -3
  26. package/src/WireOnboarding.tsx +178 -34
  27. package/src/activation/wireActivation.ts +13 -7
  28. package/src/analytics/analyticsEvent.ts +16 -1
  29. package/src/analytics/analyticsFacade.ts +11 -10
  30. package/src/analytics/currentSession.ts +6 -20
  31. package/src/analytics/eventQueue.ts +71 -1
  32. package/src/analytics/index.ts +1 -1
  33. package/src/analytics/reportClientEvent.ts +157 -38
  34. package/src/cards/PermissionCard.tsx +438 -0
  35. package/src/cards/index.ts +7 -0
  36. package/src/config/wireConfigFromEnv.ts +1 -10
  37. package/src/context/deviceId.ts +77 -16
  38. package/src/context/userContext.ts +4 -15
  39. package/src/identity/identityRecord.ts +123 -0
  40. package/src/identity/userIdentity.ts +45 -9
  41. package/src/illustrations/defaultIllustrations.tsx +44 -3
  42. package/src/index.ts +44 -4
  43. package/src/permissions/index.ts +64 -0
  44. package/src/permissions/permissionCopy.ts +87 -0
  45. package/src/permissions/permissionEvents.ts +76 -0
  46. package/src/permissions/permissionMemory.ts +88 -0
  47. package/src/permissions/placement.ts +88 -0
  48. package/src/permissions/types.ts +131 -0
  49. package/src/session/persistedSession.ts +10 -3
  50. package/src/session-analytics/useLifecycleEvents.ts +10 -1
  51. package/src/types.ts +77 -1
  52. package/src/utils/deriveAnswers.ts +6 -2
  53. package/src/utils/readProgress.ts +4 -0
  54. package/src/utils/warnInDev.ts +33 -0
  55. package/src/components/DoneBlock.tsx +0 -37
package/AGENTS.md CHANGED
@@ -48,6 +48,7 @@ subpaths are optional secondary feature modules; import one only if you use it.
48
48
  - `themeFromBrand({ primary })`: derive a full theme from one brand color.
49
49
  - `mergeTheme`, `defaultOnboardingTheme`, `OnboardingThemeProvider`, `useOnboardingTheme`.
50
50
  - `defaultIllustrations`: dependency-free fallback art; spread your own over it.
51
+ - `WIRE_PERMISSION_EVENTS`, `resolvePermissionCopy(...)`: the canonical permission-funnel names and the shipped rationale copy behind `permissionScreens` (see the prop below). The kit imports no native permission module; the host injects `request`.
51
52
  - `createRevenueCatBridge({ analytics, entitlementId })`: the RevenueCat purchase funnel (see "RevenueCat" below).
52
53
  - `activationJoinContext(deviceKey)`: builds the `userContext` value that joins an onboarding session to the app's later events. Every `<WireOnboarding>` needs it (see step 7).
53
54
  - `resolveAutoDeviceKey({ appId, storage })`: the kit's own persisted per-install `device_key`, for a host that owns none.
@@ -65,9 +66,10 @@ subpaths are optional secondary feature modules; import one only if you use it.
65
66
  | `illustrations` | `Record<string, ReactNode>` | no | Host artwork for `InterstitialCard`, keyed by name. `{ ...defaultIllustrations, ...myArt }`. |
66
67
  | `icons` | `Record<string, ReactNode>` | no | Host icon nodes keyed by the semantic vocabulary name the AI emits (`{ instagram: <BrandIg/> }`). Checked FIRST: use it to brand an icon, to add names of your own, or to supply icons without installing `@expo/vector-icons`. Unlisted names fall back to that optional peer, then to no icon. |
67
68
  | `validators` | `Record<string, StepValidator>` | no | Per base-question key (e.g. `username`). Blocks advance + inline error. |
69
+ | `permissionScreens` | `PermissionScreenConfig[]` | no | Priming screens injected mid-flow (notifications first). `{ permission, placement?, request, getStatus?, openSettings?, copy?, illustration?, onResult? }`. The OS dialog opens ONLY on the primary tap, never on mount; "Maybe later" advances without spending the one native prompt. `placement` is `"start"`, `{ afterCard: n }` or `"beforeEnd"` (default), and an `afterCard` past the end of the stream clamps to `"beforeEnd"`. Zero new dependencies: the host injects `request`, the kit imports no native permission module. Shown once per session and, with `storage`, across an app kill. Not a question: no `key`/`slot_id`, nothing in `answers`, completion never blocks on a grant. |
68
70
  | `onSkip` | `() => void` | no | Retained for back-compat. Per-question Skip is now internal (the kit advances one question on `skippable` screens), so this is no longer wired to that control. |
69
71
  | `onError` | `(err) => void` | no | Backend error/timeout after retries. Host recovery (e.g. static onboarding). `fallbackFlow` takes precedence over it. |
70
- | `onEvent` | `(e: OnboardingEvent) => void` | no | Lifecycle events for analytics (kit owns the loop). Variants: `started`, `resumed`, `turn`, `error`, `retry`, `fallback` (see `OnboardingEvent`). |
72
+ | `onEvent` | `(e: OnboardingEvent) => void` | no | Lifecycle events for analytics (kit owns the loop). Variants: `started`, `resumed`, `turn`, `error`, `retry`, `fallback`, `permission` (see `OnboardingEvent`). |
71
73
  | `copy` | `Partial<OnboardingCopy>` | no | Localize built-in English strings. |
72
74
  | `approxScreens` | `number` | no | Paces the bar; backend `progress.total` wins, so usually unneeded. |
73
75
  | `startMessage` | `string` | no | First backend message. Default `"start"`. |
package/CHANGELOG.md CHANGED
@@ -3,6 +3,264 @@
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.2] — 2026-07-31
7
+
8
+ The headline is a fix: every `track()` / `screen()` `app_event` was being silently discarded by the
9
+ server behind an HTTP 200, on every kit version since 0.11.0. This release also ships the injectable
10
+ 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.
13
+
14
+ ### Fixed
15
+
16
+ - **`ts` crosses the wire as ISO8601, so `app_event`s actually land.**
17
+ (`analytics/reportClientEvent.ts`) The server's event model declares `ts: str | None` and pydantic
18
+ v2 does not coerce a number into it, while the offline queue has stamped `ts = Date.now()` (epoch
19
+ ms) on every queued event since 0.11.0 — so `POST /v1/events` answered HTTP 200 with
20
+ `{written: 0, skipped: N, errors: [{field: "ts", reason: "validation_error"}]}` and 100% of
21
+ `track()` / `screen()` analytics evaporated behind a green response. `buildEventsRequest` — the
22
+ single choke point all four send paths share (offline queue, fire-and-forget, awaitable,
23
+ session-start) — now serializes a numeric `ts` to an ISO8601 UTC string on its way out. The queue
24
+ keeps its numeric stamp (the identical-JSON de-dup signature depends on it), a persisted 0.13.0
25
+ backlog is converted as it drains, a caller-set ISO string passes through untouched, and a
26
+ non-finite or out-of-range number drops the field from that one event instead of killing the batch.
27
+ - **The discard warning names the field the server refused.** (`readEventsAck`) The server's
28
+ `errors[]` entries carry a `field` alongside `reason`; the ack reader threw it away, so a dev build
29
+ warned `validation_error` with no address. It now prints `validation_error (field: ts)` — the
30
+ difference between a one-line fix and an investigation.
31
+
32
+ ### Added
33
+
34
+ Injectable mid-flow permission screens, starting with notifications.
35
+
36
+ Onboarding is where apps ask for notifications and where most of them lose the ask: iOS grants an
37
+ app exactly ONE native prompt for its entire lifetime, so firing it from a mount effect on screen
38
+ one spends it on a user who has been told nothing, and the only route back is a Settings trip almost
39
+ nobody makes. This release ships the priming pattern as a first-class part of the flow. You declare
40
+ a screen, it explains why in your words, and the OS dialog opens only on the user's primary tap.
41
+ "Maybe later" advances the flow with the prompt unspent.
42
+
43
+ Nothing changes for a host that configures no screen: the prop is optional and every path around it
44
+ is byte-identical.
45
+
46
+ - **`permissionScreens` on `<WireOnboarding>`** (`permissions/types.ts`, `cards/PermissionCard.tsx`) -
47
+ one or more priming screens injected into the server-driven card stream at a position you choose.
48
+ The screen carries customizable rationale copy, kit-quality defaults for notifications, optional
49
+ artwork through the existing illustrations registry, and an `onResult` callback per screen.
50
+ **DEPENDENCY-FREE, the RevenueCat-bridge idiom:** the kit imports no `expo-notifications`, no
51
+ `react-native-permissions`, nothing native. The host injects `request` and optionally `getStatus`
52
+ and `openSettings`, exactly the way it hands the RevenueCat bridge real RevenueCat objects. The
53
+ README shows the five-line wiring.
54
+ - **The priming rule, enforced in code and pinned by a canary.** There is no path from mount, from
55
+ an effect, from a timer or from render to the host's `request`; only the primary press handler
56
+ reaches it. The optional `getStatus` probe is a READ that never prompts, and all it decides is
57
+ which primary the screen offers: ask, open settings (status `blocked`, where an ask would show
58
+ nothing at all), or a plain continue (already granted, or a host that supplied no `request`, where
59
+ the kit declines rather than fabricating a button that does nothing).
60
+ - **A watchdog on the host's `request`, which fabricates nothing.** Asking sets a busy state that
61
+ disables both controls, which is right while an OS dialog may be open and fatal if the host's
62
+ `request` never settles at all (a swallowed native callback, a promise nobody resolves): the
63
+ screen would be a dead end with nothing tappable, and completion could never fire. A generous 90
64
+ second ceiling now hands the controls back so the user can retry or skip. On expiry it records NO
65
+ outcome and emits NO event, only a dev warning, because a pending request is not a denial and a
66
+ person can legitimately sit on a permission dialog for minutes. First settlement wins: if the
67
+ original request answers late, after the controls were handed back and possibly after a second
68
+ attempt already resolved, it advances nothing and emits nothing.
69
+ - **Placement semantics with a clamp** (`DEFAULT_PERMISSION_PLACEMENT`, `selectDuePermissionScreen`)
70
+ - `"start"`, `{ afterCard: n }` and `"beforeEnd"` (the default), resolved against the card about
71
+ to render, because the stream length is server-driven and varies per user. An `afterCard` the flow
72
+ never reaches degrades to `"beforeEnd"` instead of silently never showing, which is the failure
73
+ mode that raises no error anywhere and leaves the host looking at a funnel that reads zero.
74
+ - **`WIRE_PERMISSION_EVENTS`** plus `permissionEventName`, `permissionEventProps` and
75
+ `normalizePermissionStatus` (`permissions/permissionEvents.ts`) - the canonical funnel names
76
+ (`wire_permission_screen_shown`, `wire_permission_primer_accepted`, `wire_permission_granted`,
77
+ `wire_permission_denied`, `wire_permission_skipped`, `wire_permission_settings_opened`), mirroring
78
+ `WIRE_PURCHASE_EVENTS`. They land as `app_event` `question_key` values on the same `/v1/events`
79
+ path, carrying the same device snapshot and `user_context`, so the `device_key` join that makes
80
+ every other number real covers these too. Each moment is also surfaced on `onEvent` as
81
+ `{ type: "permission", ... }`.
82
+ - **`resolvePermissionCopy`, `NOTIFICATIONS_PERMISSION_COPY`, `GENERIC_PERMISSION_COPY`,
83
+ `DEFAULT_PERMISSION_COPY`** - the shipped English plus per-line overrides. An override that is
84
+ `undefined` or empty is IGNORED rather than allowed to blank a button, because hosts build this
85
+ object from an i18n layer where a missing translation resolves to `undefined`.
86
+ - **Once per session, kept true across an app KILL** (`permissionStorageKey`,
87
+ `loadSettledPermissions`, `saveSettledPermissions`, `readSettledPermissions`,
88
+ `clearSettledPermissions`). The kit resumes a killed onboarding into the SAME backend session, so
89
+ without a persisted record the resumed mount would re-show a screen the user already answered,
90
+ which on the ask path means a second attempt at the one prompt iOS grants. The record is scoped to
91
+ the session id, one entry, so a genuinely new onboarding still starts clean with nothing having to
92
+ expire it. Same host-injected `storage`, same read ceiling, same best-effort discipline: a
93
+ rejecting adapter costs a re-ask on resume, never a broken or gated flow.
94
+ - **`PermissionCard` and `PERMISSION_CARD_NAME`** - the screen registered as an SDK component, so a
95
+ later server-emitted placement (AI-chosen timing) adopts this exact screen with no rewrite.
96
+ Deliberately NOT part of `onboardingComponents`: that array is what the device advertises as
97
+ renderable, and a backend told it may emit a permission screen could emit one into a host that
98
+ injected no `request`.
99
+ - **A `notifications` entry in `defaultIllustrations`** so a notification screen is never a blank
100
+ box with no host wiring. Register your own under the same name to override it.
101
+
102
+ ### Changed
103
+
104
+ - **`OnboardingEvent` gains a `permission` variant** and `toAnalyticsEvent` maps it to its own
105
+ canonical `wire_permission_*` name rather than folding it into an onboarding one. Additive: a host
106
+ that ignores the new variant is unaffected, and every existing variant maps exactly as before.
107
+ - **A permission screen is NOT a question, structurally.** It sends nothing to the backend, appends
108
+ nothing to the thread, mints no `key` and no `slot_id`, and never touches the progress step, so
109
+ `deriveAnswers`, `readProgress` and the completion semantics cannot tell it happened. Completion
110
+ never blocks on a grant: grant, deny, skip and blocked all continue the flow, and a host callback
111
+ that throws (a native module blowing up, a failed scheduling call) is caught rather than allowed
112
+ to strand the user on the screen.
113
+ - **Notification SCHEDULING is deliberately out of scope.** `onResult` is the seam: schedule your
114
+ first local reminder there, with your own call, the moment a grant lands.
115
+
116
+ ## [0.13.0] — 2026-07-27
117
+
118
+ The identity-provenance release. Every id-layer defect fixed here is one omission wearing five faces:
119
+ `session_id` and `device_key` are bare `string`s minted independently by four subsystems, and nothing
120
+ anywhere recorded WHERE a given id came from. So a rejecting storage adapter's in-memory id was
121
+ indistinguishable from a persisted one, an app-OPEN session id could be posted into a field meaning the
122
+ ONBOARDING session, an auto-minted key could be injected beside a device id the host demonstrably owned
123
+ on another surface, and the join key could change identity mid-mount — each of them silently, each of
124
+ them returning success. This release gives ids provenance and teaches every consumer to refuse a
125
+ fabricated one.
126
+
127
+ Nothing here changes the wire for a correctly-wired host. The behaviour changes all move in one
128
+ direction: the kit now declines and says so, where it used to fabricate and return `true`.
129
+
130
+ ### Added
131
+
132
+ - **`resolveIdentity(...)`, `hostIdentity(...)` and the `IdentityRecord` type** (`identity/identityRecord.ts`) —
133
+ the substrate. One record, `{ value, space, source, durable }`, parked on the same `Symbol.for`
134
+ registry pattern the repo already uses for the current session id, the auto device key and activation
135
+ revalidation, so every inlined bundle copy addresses ONE registry. `space` is
136
+ `onboarding-session | app-session | device`, `source` is `host | auto`, and `durable` says whether the
137
+ value was actually persisted. A host-sourced record is recorded on a process-wide registry, which is
138
+ what lets one surface notice that another surface owns a device key it was not given.
139
+ ⚠️ Deliberately NOT a branded-types refactor — `OnboardingSessionId` / `AppSessionId` / `DeviceKey`
140
+ across every signature is real value and is deferred, because it touches every file.
141
+ - **`hydrateDeviceIdentity(...)`** (`context/deviceId.ts`) — the provenance-carrying sibling of
142
+ `hydrateAutoDeviceKey`. Same awaited read, but it answers "is this an id this install will KEEP?"
143
+ instead of only "what is the id?". `hydrateAutoDeviceKey` is unchanged and still returns a bare string.
144
+ - **`OnboardingProgress.slot_id`** — an optional STABLE per-slot identity for a screen, read off
145
+ `props.progress` by `readProgress` and preferred over `key` by `deriveAnswers`. `key` is authored from
146
+ the question's prompt text (tenant flows slugify it and cut at 32 chars), so re-wording a question mints
147
+ a NEW key and a host reading `answers.interests` silently starts reading `undefined`.
148
+ ⚠️ ~~**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
150
+ `progress` for every AI-generated question, as `adaptive_<n>`, and for a configured question
151
+ whenever the tenant set one. Where no slot is configured the field is absent and
152
+ every path degrades to byte-identical 0.12.2 behaviour. Every fallback is decided
153
+ PER CARD, never cached as a per-session "this backend supports slots" verdict, so a thread that mixes
154
+ slotted and unslotted cards — the real shape during a rollout — keys each card correctly.
155
+ - **`allowAppSessionFallback` on `identifyOnboarding`** — the opt-in switch for the tier-3 behaviour
156
+ described under Changed. Default `false`.
157
+ - **`resetEventQueueKeys()`** (`@wireai/activation/analytics`, test-only) — clears
158
+ the claimed-queue-key registry. A real relaunch is a new process; a test that simulates one in-process
159
+ needs this or its second queue reads as a concurrent sibling.
160
+ - **A first test file for `deriveAnswers`** (`utils/deriveAnswers.test.ts`), table-driven over real thread
161
+ shapes: progress key present and absent, slugified prose, multi-select, every NL wrapper the SDK
162
+ produces, and the threads that must produce NO entry.
163
+
164
+ ### Changed
165
+
166
+ - **⚠️ BEHAVIOR CHANGE: a `storage` adapter that FAILS no longer earns an injected join key.** The
167
+ auto-join gate was a bare presence check on the `storage` prop — never the SUCCESS of the write — and
168
+ `hydrateAutoDeviceKey` resolves to the in-memory mint on every failure branch, so a REJECTING adapter
169
+ (a locked / full / permission-denied AsyncStorage, the most common real breakage) injected a FRESH
170
+ `wdev_*` on every launch. Probe-verified: two launches, two different keys. That is strictly worse than
171
+ injecting nothing — the server counts `min_sessions` by distinct opens grouped on `device_key`, so a
172
+ per-launch key makes that counter structurally incapable of exceeding 1 AND inflates distinct-device
173
+ counts. The gate now reads `IdentityRecord.durable`, true only when the id was adopted from storage or
174
+ written to it successfully, so a broken adapter lands on the same "declined, and here is why" path as
175
+ no adapter at all. The existing dev warning was widened to name this third reason.
176
+ Who this changes: a host whose storage was silently broken. It was already getting corrupt data; it now
177
+ gets no join key and a warning that names the cause.
178
+ - **⚠️ BEHAVIOR CHANGE: `identifyOnboarding` no longer binds to the app-open session by default, and its
179
+ return type widened to `"onboarding" | "app_session" | false`.** Tier 3 fell back to
180
+ `getCurrentSessionId()` — the per-OPEN session — and posted it in the `session_id` field, which on this
181
+ endpoint means the ONBOARDING session, then returned `true`. Two disjoint id spaces share that field, so
182
+ the row it wrote could never join the onboarding funnel while the host was told it had worked. It fired
183
+ in exactly the DOCUMENTED post-completion case, because completion clears the persisted session. The
184
+ fallback is now behind `allowAppSessionFallback` and, when it does fire, says so in the return value.
185
+ ⚠️ `"onboarding"` is truthy, so `if (await identifyOnboarding(...))` behaves identically at runtime;
186
+ only an explicit `: boolean` annotation needs updating.
187
+ - **⚠️ BEHAVIOR CHANGE: `wire.track()` returns `false` for an event the server DISCARDED.**
188
+ `POST /v1/events` answers HTTP 200 with `{ ok, written, skipped }` and counts a refused event in
189
+ `skipped`, so `reportClientEventsAwait` — which read `res.ok` alone — resolved `true` for an event that
190
+ was thrown away, and `wire.track` then bumped decision revalidation, making every subscribed gate
191
+ re-fetch against a stream the action never entered. It now reads the ack. Backward compatible by
192
+ construction: ONLY an explicit positive `skipped` demotes a 200, so an old server that sends no such
193
+ field, an unparseable body, or a response with no `.json` at all still resolves `true` — the change can
194
+ produce no false negatives.
195
+ - **The join decision is FROZEN at the moment the loader gate opens.** `userContext` feeds the `llm` memo
196
+ and wireai-rn recreates its A2A adapter whenever that identity changes, resetting `contextId`. So a host
197
+ resolving its device key ASYNCHRONOUSLY — the default shape of any host reading its id out of async
198
+ storage — rendered once without it, got `wdev_*` injected, then had the real key land a tick later and
199
+ rebuild the adapter, DROPPING the server-learned session id and RESTARTING the user's onboarding
200
+ mid-flow with an orphaned session left behind. The session-start metadata now reads a frozen snapshot,
201
+ exactly as `startupUserIdRef` already did for the user id. Client events deliberately keep reading the
202
+ LIVE context: they carry no adapter, so late host context enriches them at no risk.
203
+ - **A host device key on ANOTHER surface is no longer silent.** 0.12.2 suppressed the missing-join-key
204
+ warning whenever injection succeeded — right when the host genuinely owns no device id, wrong when it
205
+ owns one and forgot it on this mount. That host went from a loud warning to a silent THIRD id space. Any
206
+ surface constructed with a host-supplied device key (`createAnalytics`, `createWireActivation`,
207
+ `useLifecycleEvents`, `<WireOnboarding userContext={...}>`) now records it, scoped per `appId`, and a mount
208
+ that injected its own key while the process demonstrably owns a host one warns and prints both. It warns
209
+ rather than declining, because declining would leave the onboarding side with no key at all; it does not
210
+ silently ADOPT the other key, because that is the kit guessing which of two ids a host meant on the
211
+ strength of construction order.
212
+ - **Two event queues for one `appId` no longer share one storage slot.** The default key was derived from
213
+ `appId` alone, so two `createAnalytics` instances for one tenant — the documented double-wiring — shared
214
+ ONE persisted backlog while keeping SEPARATE in-memory buffers: each `persist()` overwrote the other's
215
+ blob, a queue draining to empty called `removeItem` and DELETED a sibling's still-pending events, and on
216
+ relaunch the survivors were loaded by both and sent twice. The second claimant of a default key now gets
217
+ `…#2` and a dev warning naming the fix. Only the appId-derived DEFAULT is ever rotated — an EXPLICIT
218
+ `storageKey` is a host declaring which slot it owns (`useLifecycleEvents` does exactly that and
219
+ re-creates its queue on every remount, where rotation would be a worse bug). Only claimed when there IS
220
+ storage; a storage-less queue never touches the key.
221
+ - **A failed device-key hydration is retried instead of cached for the process lifetime.** A transient
222
+ cold-boot storage lock used to be permanent: the settled failure stayed parked on the registry and every
223
+ later caller joined it. A degraded outcome now releases its latches so the next caller starts a fresh read.
224
+ - **The DISCARDED warning covers all FOUR send paths and stops naming a cause the kit never checked.** It
225
+ used to end "the usual cause is an event with a missing or empty `session_id`" — but since
226
+ `ensureCurrentSessionId` shipped, no kit path can emit an event without one, making that the LEAST likely
227
+ explanation and sending every reader to the one place the problem is not. It now reports what the server
228
+ actually said (`skipped`, `written`, and any per-event `reasons` once the server sends them) and admits
229
+ when the endpoint gave no reason. The two `reportClientEvent*` paths — which carry `dropped`, `identify`,
230
+ `client_fallback` and every `wire.track` — join the offline queue and session-start in consuming the ack.
231
+ - **`validators` are looked up by `slot_id` first, then `progress.key`.** Host validators live in the SAME
232
+ key namespace `deriveAnswers` files answers under, so moving the answer key to the slot without moving
233
+ this lookup would have silently UNBOUND every host validator: no error, no warning, values simply stop
234
+ being checked. The fallback keeps a validator map written against today's keys working while a server
235
+ rolls slots out.
236
+
237
+ ### Removed
238
+
239
+ - **`DoneBlock`** — self-`@deprecated` since 0.1.x, unused internally since the terminal screen became
240
+ `CompletionView`, and referenced by nothing in `src/` or the playground. The component file is deleted.
241
+ - **`RESERVED_USER_CONTEXT_KEYS`** — read by nothing, including its own module: the namespacing it claims
242
+ to govern is enforced by `EXTRA_KEY_PREFIX`. It was a documentation constant shaped like an invariant.
243
+
244
+ ### Internal
245
+
246
+ - The FOUR duplicate `warnInDev` copies (`WireOnboarding`, `config/wireConfigFromEnv`,
247
+ `analytics/analyticsFacade`, `analytics/currentSession`) collapse into `utils/warnInDev`, which returns
248
+ whether it ACTUALLY warned — the property `currentSession`'s once-flag depends on, and the only reason
249
+ its copy differed. The stated rationale for that copy (keeping the module import-free for the
250
+ tree-shaken analytics bundle) had already lapsed: it imports `makeSessionId` from a sibling. The new
251
+ module has no imports of its own, and the `treeShake` canary still holds the real guarantee.
252
+ - The two byte-identical trim helpers (`context/userContext.cleanString`, `activation/wireActivation.clean`)
253
+ collapse into one shared `cleanString`, not re-exported from the barrel.
254
+ - **The canary suite can observe a payload for the first time.** Every `onComplete` mock was typed with
255
+ ZERO parameters, so `OnboardingResult` was structurally unobservable and every assertion could only be a
256
+ call count — the mechanism behind "500 green tests, zero data". `flow.test.tsx` now captures the argument
257
+ and asserts the answers map end to end. The scripted `wireai-rn` mock also appends the USER turn to the
258
+ thread, as the real SDK does; without it the mocked thread was assistant-only, so `deriveAnswers` had
259
+ nothing to pair and `answers` was always `{}` under test regardless of the implementation.
260
+ - `mountCanary` gained `rerender()`, the seam for a prop that arrives LATE. The churn loop runs entirely
261
+ inside the initial `act`, so without it there was no way to re-render a mount after an async gate had
262
+ settled — and the whole "value changed mid-flow" defect class was untestable.
263
+
6
264
  ## [0.12.2] — 2026-07-27
7
265
 
8
266
  The auto-join patch. 0.12.1 taught the kit to complain about a missing join key; this one teaches it
@@ -135,7 +393,7 @@ or a client-side default.
135
393
 
136
394
  ## [0.12.0] — 2026-07-27
137
395
 
138
- The release Myelino 2.2.0 pins. Ships the RevenueCat path (#52), the identity/counting audit,
396
+ The release the first production consumer pins. Ships the RevenueCat path (#52), the identity/counting audit,
139
397
  the session-id fallback contract, and the transport/config fixes behind a silently dead lifecycle
140
398
  stream (a consumer's `first_open` read 6 all-time while the emitting code was deployed).
141
399
 
package/README.md CHANGED
@@ -234,7 +234,8 @@ 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
- | `validators` | `Record<string, StepValidator>` | Per-step, keyed by base-question key (e.g. `username`). Blocks advance + shows an inline error. |
237
+ | `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
+ | `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). |
238
239
  | `onSkip` | `() => void` | User skipped. |
239
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. |
240
241
  | `onEvent` | `(e: OnboardingEvent) => void` | `started` / `turn` / `error` — recover per-turn analytics since the kit owns the loop. |
@@ -247,7 +248,7 @@ import { WireOnboarding } from "@wireai/activation";
247
248
  | `sessionTtlMs` | `number` | How long a persisted session id stays resumable. Default `3600000` (1h, the backend's session TTL). With `storage` only. |
248
249
  | `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. |
249
250
  | `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). |
250
- | `autoJoinKey` | `boolean` | Opt OUT of the automatic join key. Default `true`. When this prop is left alone, `storage` is present, 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)). |
251
+ | `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)). |
251
252
  | `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). |
252
253
 
253
254
  ## Helpers (the reusable substrate)
@@ -340,7 +341,9 @@ const contextIdRef = useRef<string>();
340
341
  await identifyOnboarding({ config, userId: newUser.id, contextId: contextIdRef.current });
341
342
  ```
342
343
 
343
- If you passed `storage` to `<WireOnboarding>`, you can omit `contextId` and hand `identifyOnboarding` the same `storage` (plus `appId` or `persistKey`) and it recovers the persisted session id itself. That works while the session is still persisted (before completion clears it), so the captured-`contextId` path above is the safe one post-completion. `identifyOnboarding` is fire-and-forget and never throws; it resolves `true` when it dispatched an `identify` and `false` when it could not (no user id, no server url, or no resolvable session).
344
+ If you passed `storage` to `<WireOnboarding>`, you can omit `contextId` and hand `identifyOnboarding` the same `storage` (plus `appId` or `persistKey`) and it recovers the persisted session id itself. That works while the session is still persisted (before completion clears it), so the captured-`contextId` path above is the safe one post-completion. `identifyOnboarding` is fire-and-forget and never throws. Since 0.13.0 it resolves the id SPACE it bound rather than a bare boolean: `"onboarding"` when it attached to the A2A `contextId` (the one that attributes the funnel), `"app_session"` when you opted into `allowAppSessionFallback` and it fell back to the live per-open app session, and `false` when it could not bind at all (no user id, no server url, or no resolvable session). `"onboarding"` is truthy, so an existing `if (await identifyOnboarding(...))` behaves identically.
345
+
346
+ ⚠️ **The app-session fallback is now opt-in and off by default.** Before 0.13.0 it ran unconditionally: with no captured `contextId` it posted the per-OPEN session id in the `session_id` field — which on this endpoint means the ONBOARDING session — and returned `true`. Those are two different id spaces sharing one wire field, so the row it wrote could never join the onboarding funnel while the caller was told it had worked.
344
347
 
345
348
  **No PII.** The onboarding `userId` prop is length-bound only; the kit cannot detect an email for you. Keep it opaque, the same rule as the onboarding `userContext` prop.
346
349
 
@@ -606,6 +609,87 @@ icons={{
606
609
  Icons are decorative: the label carries the meaning, so an icon stays hidden from screen readers
607
610
  and never becomes an option's accessible name.
608
611
 
612
+ ## Permission screens (the priming pattern)
613
+
614
+ Onboarding is where apps ask for notifications, and it is where most of them lose the ask. iOS gives an app **one** native notification prompt for its entire lifetime. Fire it from a mount effect on screen one and you have spent it on a user who has been told nothing, and the only route back is a trip through the Settings app that almost nobody makes.
615
+
616
+ So the kit ships the priming pattern. You inject a screen into the flow, it explains why in your words, and the OS dialog opens **only** when the user taps the primary button. "Maybe later" advances the flow with the prompt still unspent, so you can ask again in a better moment.
617
+
618
+ ```tsx
619
+ import * as Notifications from "expo-notifications";
620
+ import { WireOnboarding } from "@wireai/activation";
621
+
622
+ <WireOnboarding
623
+ config={config}
624
+ onComplete={persist}
625
+ permissionScreens={[
626
+ {
627
+ permission: "notifications",
628
+ placement: "beforeEnd",
629
+ request: async () => {
630
+ const { status, canAskAgain } = await Notifications.requestPermissionsAsync();
631
+ return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
632
+ },
633
+ getStatus: async () => {
634
+ const { status, canAskAgain } = await Notifications.getPermissionsAsync();
635
+ return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
636
+ },
637
+ onResult: (_permission, outcome) => {
638
+ if (outcome === "granted") scheduleFirstReminder();
639
+ },
640
+ },
641
+ ]}
642
+ />
643
+ ```
644
+
645
+ That is the whole integration, and **the kit adds no dependency for it**. It imports no `expo-notifications`, no `react-native-permissions`, nothing native, the same way the RevenueCat bridge takes real RevenueCat objects without depending on `react-native-purchases`. You own the native call; the kit owns the screen, the timing, and the funnel.
646
+
647
+ ### Where the screen lands
648
+
649
+ The stream is server-driven, so its length changes per user. A placement is therefore resolved against the card about to render, not against a fixed index:
650
+
651
+ | `placement` | Where it shows |
652
+ |---|---|
653
+ | `"beforeEnd"` (default) | Right before the terminal recap, once the user has invested in the flow. |
654
+ | `"start"` | Before the first question. |
655
+ | `{ afterCard: 2 }` | After two questions, so between card 2 and card 3. |
656
+
657
+ An `afterCard` the flow never reaches **clamps to `"beforeEnd"`** instead of silently never showing. That matters more than it sounds: a screen that never appears raises no error anywhere, and the only evidence is a permission funnel that reads zero forever.
658
+
659
+ ### What it guarantees
660
+
661
+ - **The dialog is only ever reached from the primary tap.** No mount effect, no timer, no auto-fire. `getStatus` is a read and never prompts; it only decides which primary the screen offers.
662
+ - **Already blocked?** The primary becomes "Open settings", because an `ask` there would open nothing at all.
663
+ - **Once per session, and it survives an app kill.** With `storage`, a resumed session does not re-ask (the record is keyed to the session id, so a genuinely new onboarding still starts clean).
664
+ - **Completion never blocks on a grant.** Grant, deny, skip and blocked all continue the flow.
665
+ - **It is not a question.** Nothing is sent to the backend, nothing enters the thread, no `key` or `slot_id` is minted, and `onComplete`'s `answers` are identical to the same flow with no screen configured.
666
+
667
+ ### Copy and artwork
668
+
669
+ The kit ships English good enough to ship. Override any single line and the rest of the default stays:
670
+
671
+ ```tsx
672
+ copy: { title: "Never miss a session", primaryLabel: "Turn on reminders" }
673
+ ```
674
+
675
+ Artwork comes from the same `illustrations` registry the cards use, keyed by the permission name, so `illustrations={{ notifications: <MyBell /> }}` is enough. The kit's dependency-free default is used when you register nothing.
676
+
677
+ ### The numbers
678
+
679
+ Each screen reports through the same `/v1/events` path and the same `device_key` join as the rest of the funnel, and every moment is also surfaced on `onEvent` as `{ type: "permission", ... }`:
680
+
681
+ | Event | Fires when |
682
+ |---|---|
683
+ | `wire_permission_screen_shown` | The primer became visible. The denominator. |
684
+ | `wire_permission_primer_accepted` | The primary was tapped, so the OS dialog is about to open. |
685
+ | `wire_permission_granted` / `wire_permission_denied` | What the OS answered (a permanently blocked answer reports as denied with `status: "blocked"`). |
686
+ | `wire_permission_skipped` | "Maybe later". The one native prompt was not spent. |
687
+ | `wire_permission_settings_opened` | A blocked user was sent to Settings. |
688
+
689
+ The gap between `wire_permission_screen_shown` and `wire_permission_primer_accepted` is the number worth watching: it tells you whether your rationale copy works, and it costs nothing to be wrong about, because a user who does not tap has not burned anything.
690
+
691
+ **Scheduling notifications is deliberately out of scope.** `onResult` is the seam: schedule your first local reminder there, with your own `expo-notifications` call, the moment a grant lands.
692
+
609
693
  ## Backend coupling
610
694
 
611
695
  The backend (`wire-rn/examples/dynamic-onboarding/server`) is the source of truth
@@ -1,6 +1,6 @@
1
1
  export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-CF_eHwzC.mjs';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-BlCeDP0f.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 resolveAutoDeviceKey, F as setCurrentSessionId, G as toAnalyticsEvent } from '../currentSession-BlCeDP0f.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
4
  import '../types-CNUqMK0D.mjs';
5
5
  import '../types-BKfpdZzX.mjs';
6
6
  import '../types-BcmagF6K.mjs';
@@ -1,6 +1,6 @@
1
1
  export { R as ReportAppEventOptions, r as reportAppEvent } from '../transport-DsRe4epC.js';
2
- import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-BxEB37xt.js';
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 resolveAutoDeviceKey, F as setCurrentSessionId, G as toAnalyticsEvent } from '../currentSession-BxEB37xt.js';
2
+ import { W as WireUserContext, E as EventQueueOptions } from '../currentSession-DOVZEWJl.js';
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-DOVZEWJl.js';
4
4
  import '../types-Buj9Lw9t.js';
5
5
  import '../types-BKfpdZzX.js';
6
6
  import '../types-BcmagF6K.js';