@wireai/activation 0.14.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/AGENTS.md +96 -21
  2. package/CHANGELOG.md +780 -0
  3. package/INTEGRATION_PROMPT.md +61 -23
  4. package/README.md +110 -31
  5. package/dist/analytics/index.d.mts +35 -13
  6. package/dist/analytics/index.d.ts +35 -13
  7. package/dist/analytics/index.js +288 -127
  8. package/dist/analytics/index.js.map +1 -1
  9. package/dist/analytics/index.mjs +288 -127
  10. package/dist/analytics/index.mjs.map +1 -1
  11. package/dist/coachmarks/index.d.mts +15 -1
  12. package/dist/coachmarks/index.d.ts +15 -1
  13. package/dist/coachmarks/index.js +120 -22
  14. package/dist/coachmarks/index.js.map +1 -1
  15. package/dist/coachmarks/index.mjs +120 -22
  16. package/dist/coachmarks/index.mjs.map +1 -1
  17. package/dist/{currentSession-CUvTOchb.d.mts → currentSession-Bz7G6lno.d.mts} +35 -39
  18. package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-z-CZ55ad.d.ts} +35 -39
  19. package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
  20. package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
  21. package/dist/index.d.mts +5 -2
  22. package/dist/index.d.ts +5 -2
  23. package/dist/index.js +125 -36
  24. package/dist/index.js.map +1 -1
  25. package/dist/index.mjs +125 -36
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/questionnaire/index.d.mts +3 -16
  28. package/dist/questionnaire/index.d.ts +3 -16
  29. package/dist/questionnaire/index.js +202 -46
  30. package/dist/questionnaire/index.js.map +1 -1
  31. package/dist/questionnaire/index.mjs +203 -47
  32. package/dist/questionnaire/index.mjs.map +1 -1
  33. package/dist/reviews/index.d.mts +10 -6
  34. package/dist/reviews/index.d.ts +10 -6
  35. package/dist/reviews/index.js +269 -52
  36. package/dist/reviews/index.js.map +1 -1
  37. package/dist/reviews/index.mjs +270 -53
  38. package/dist/reviews/index.mjs.map +1 -1
  39. package/dist/showcase/index.d.mts +1 -1
  40. package/dist/showcase/index.d.ts +1 -1
  41. package/dist/showcase/index.js +106 -20
  42. package/dist/showcase/index.js.map +1 -1
  43. package/dist/showcase/index.mjs +107 -21
  44. package/dist/showcase/index.mjs.map +1 -1
  45. package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
  46. package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
  47. package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
  48. package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
  49. package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
  50. package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
  51. package/llms.txt +9 -9
  52. package/package.json +6 -9
  53. package/src/WireOnboarding.tsx +4 -1
  54. package/src/analytics/currentSession.ts +141 -4
  55. package/src/analytics/index.ts +6 -1
  56. package/src/analytics/reportClientEvent.ts +19 -10
  57. package/src/analytics/useAnalytics.ts +74 -15
  58. package/src/analytics/wireDoctor.ts +152 -7
  59. package/src/coachmarks/CoachmarkProvider.tsx +26 -5
  60. package/src/coachmarks/runtime.ts +156 -2
  61. package/src/coachmarks/types.ts +7 -2
  62. package/src/coachmarks/useCoachmarkTour.ts +51 -1
  63. package/src/context/deviceId.ts +72 -6
  64. package/src/features/WireFeaturesProvider.tsx +72 -12
  65. package/src/features/fetchWireFeatures.ts +49 -11
  66. package/src/features/useWireFeatures.ts +39 -3
  67. package/src/identity/identityRecord.ts +15 -2
  68. package/src/questionnaire/QuestionnaireGate.tsx +40 -1
  69. package/src/questionnaire/transport.ts +22 -8
  70. package/src/questionnaire/useQuestionnaireGate.ts +58 -7
  71. package/src/reviews/ReviewGate.tsx +125 -37
  72. package/src/reviews/decision.ts +11 -1
  73. package/src/reviews/idempotency.ts +109 -0
  74. package/src/reviews/index.ts +4 -2
  75. package/src/reviews/runtime.ts +44 -13
  76. package/src/reviews/transport.ts +39 -20
  77. package/src/reviews/types.ts +7 -0
  78. package/src/reviews/useReviewGate.ts +57 -7
  79. package/src/session-analytics/lifecycle.ts +16 -0
  80. package/src/session-analytics/useLifecycleEvents.ts +30 -2
  81. package/src/session-analytics/useSessionStart.ts +22 -2
  82. package/src/showcase/FeatureShowcase.tsx +50 -3
  83. package/src/types.ts +15 -8
  84. package/src/utils/submitResult.ts +17 -9
  85. package/src/utils/withDeadline.ts +70 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,786 @@
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.16.0] - 2026-08-20
7
+
8
+ > **MINOR, and here is the precedent it rests on:** an OPTIONAL member added to a public type is what
9
+ > made 0.14.0 and 0.15.0 minors, and this release adds `WireDoctorJoinTarget` to the
10
+ > `@wireai/activation/analytics` barrel plus `WireDoctorOptions.join`. Everything under 0.15.1 below
11
+ > ships inside this release.
12
+ >
13
+ > The heading is worded as a blocker, not as `[Unreleased]`, on purpose: this file has twice carried
14
+ > an `[Unreleased]` title long after the work shipped (see the 2026-08-09 corrections below). A
15
+ > heading that names what it is waiting for cannot go quietly stale.
16
+
17
+ ### Added
18
+
19
+ - **`wireDoctor` gained a fifth check, `join_key`** — the one failure the other four are structurally
20
+ blind to. `target`, `reachability`, `storage` and `round_trip` all answer *does an event leave and
21
+ get accepted?*. None of them answers *can it be joined?*. `user_context.device_key` is the only
22
+ thing that stitches an onboarding session to everything the app reports later, so an integration
23
+ without one writes every event, passes all four checks, and still reports a permanent **zero** in
24
+ the `activated` funnel. Two real consumers shipped exactly that, and the 0.15.1 notes recorded the
25
+ gap as future work.
26
+
27
+ The check mirrors `WireOnboarding`'s own auto-join conditions, in its order: a host-supplied
28
+ `device_key` wins outright; `autoJoinKey: false` with no host key is an unlinked funnel; no
29
+ `storage` means the kit declines to inject (an unpersisted key differs every launch, which
30
+ corrupts session counting rather than just leaving it empty); a failed `storage` check means the
31
+ same; otherwise it reads the persisted auto key, and a first run with proven-writable storage
32
+ passes because the kit will mint and persist on first mount.
33
+
34
+ - **`WireDoctorJoinTarget`**, exported from `@wireai/activation/analytics`, and
35
+ `WireDoctorOptions.join`. Pass the same `appId` / `userContext` / `autoJoinKey` you pass
36
+ `<WireOnboarding>` — anything reconstructed would diagnose a different integration than the one
37
+ that ships.
38
+
39
+ - **`isUsableIdentityValue`**, a pure predicate extracted from `resolveIdentity`, which is now its
40
+ only other caller. ⚠️ **Module-level only — deliberately NOT added to any barrel**, so it is not
41
+ part of the published surface even though its sibling `resolveIdentity` is: nothing outside the
42
+ kit needs it yet, and this repo does not grow public API speculatively. See **Guarded** for why
43
+ the doctor could not simply call `resolveIdentity`.
44
+
45
+ ### Changed — read this before upgrading
46
+
47
+ - **Omitting `join` FAILS the report** rather than skipping the check. An existing caller that passes
48
+ only `{ target, storage }` will see `report.ok` go from `true` to `false`, with a `join_key` check
49
+ whose detail begins `NOT EVALUATED` and names exactly what to pass.
50
+
51
+ This is deliberate, and it is the module's own established doctrine rather than a new invention:
52
+ `dev_only` already returns `ok: false` for the same reason, because — in that check's own words —
53
+ *"a green report that never ran is the exact failure mode this whole feature exists to remove."* A
54
+ report that never looked at the join has not earned a green either. `wireDoctor` is dev-only and
55
+ gated on `__DEV__`, so no release build and no end user is affected.
56
+
57
+ ### Guarded
58
+
59
+ - **The check is strictly READ-ONLY: one `getItem`, plus pure predicates.** It would have been
60
+ shorter to call `hydrateDeviceIdentity` and read `.durable` off the result, and that would have
61
+ been a bug: it MINTS, registers on the process-wide registry, and persists. Two things forbid it —
62
+ this module's own contract (*"mints no `globalThis` slot"*, *"never a side effect on the host"*),
63
+ and the 0.15.1 defect where a propless READER wrote into the census it read, so the host's real
64
+ `appId` registering afterwards made the census read two and the reader answered `undefined` for
65
+ the rest of the process. A diagnostic that causes the defect class it diagnoses is worse than no
66
+ diagnostic. Same reasoning for the host-key branch calling `isUsableIdentityValue` and not
67
+ `resolveIdentity`: the latter WRITES the provenance registry for a `host`-sourced value.
68
+
69
+ This is held by a test, not by this paragraph: *"join_key: READ-ONLY — it mints nothing and writes
70
+ no identity registry"* asserts both `Symbol.for` slots are still absent after a full run, and it
71
+ was mutation-checked by swapping the predicate for `resolveIdentity({ source: "host" })` and
72
+ watching it go red.
73
+
74
+ ### Docs
75
+
76
+ - `README.md`, `AGENTS.md`, `INTEGRATION_PROMPT.md` and `llms.txt` each stated the absence this
77
+ change removes — *"exactly four checks"*, *"does not check the join key"* — in four separate
78
+ places. All four are corrected: a flag falsifies every doc that named its absence, and grepping
79
+ for the new identifier finds none of them. The historical entries below are left as written; they
80
+ describe what those releases shipped.
81
+
82
+ ### Tests
83
+
84
+ 912 → **923**, 0 fail (`mcp` unmoved at 128). All eleven new tests were mutation-checked: the
85
+ unevaluated-join verdict, the appId-namespaced read, the shared identity predicate, the
86
+ storage-failed branch and the read-only guard were each reverted in turn, each took a test red, and
87
+ each was restored. The doc-compile gate was positive-controlled against the new README example.
88
+
89
+ ## [0.15.1] - 2026-08-19 — ⚠️ NEVER PUBLISHED; its contents ship in 0.16.0 above
90
+
91
+ > This version was merged, gated and audited, but npm never served it: the pre-publish audit kept
92
+ > finding defects while it sat unpublished, and by the time it was clean the surface had gained an
93
+ > export, which forced a MINOR. **Do not look for 0.15.1 on the registry — it does not exist.**
94
+ > Everything in this section is in 0.16.0.
95
+
96
+ **PATCH — and a PATCH that carries RUNTIME behaviour change, not only docs.** It began as a
97
+ docs-and-gates pass and grew, in the same unpublished version, into the audit wave below: five
98
+ silent runtime failures, then four kill switches that could not stop a first impression, then the
99
+ review gate's ambient device key. **221 non-comment lines under `src/` differ from 0.15.0**
100
+ (diffed against the 0.15.0 tree over `src/`, with comment and blank lines excluded) — so read the
101
+ `### Fixed` sections as shipped behaviour, and re-test accordingly.
102
+
103
+ It is a PATCH because **the public surface did not move**: the built type surface is
104
+ declaration-for-declaration identical to 0.15.0 (1049 normalised lines on both sides, diffed across
105
+ every emitted `.d.ts` with the content-hashed chunk names normalised away; the instrument was
106
+ positive-controlled by deleting a member and watching it report). Nothing was added, removed or
107
+ renamed on any barrel, so a consumer re-pinning 0.15.0 → 0.15.1 compiles unchanged.
108
+
109
+ > ⚠️ **This header used to read *"Docs, JSDoc comments, tests and package scripts only — every
110
+ > changed line under `src/` is inside a comment."*** That was true when it was written for the docs
111
+ > pass and became false the moment the first runtime fix landed in the same version. It is corrected
112
+ > here rather than quietly deleted, because it is the same defect class the rest of this release
113
+ > exists to fix — a claim that outlived the code it described — and it was sitting in the release's
114
+ > own front matter, which is the last thing a reader sees before trusting everything below it.
115
+
116
+ ### Pre-publish audit (2026-08-20) — added to this version before it shipped
117
+
118
+ An adversarial find-and-fix loop was run against the **tarball**, not the repo, before the
119
+ irreversible `npm publish`. Six rounds, six lenses; it exited after two consecutive rounds found
120
+ nothing new and every confirmed finding had an executable gate.
121
+
122
+ #### Fixed — the findings that audit DEFERRED, taken in the same window
123
+
124
+ The audit deliberately deferred five findings: each was real but had no path to user-visible harm
125
+ before the publish, and every edit made against an about-to-be-immutable version carries its own
126
+ risk. The version stayed unpublished long enough to spend that window, so it was spent. **No public
127
+ export was added, renamed or removed** — the built `.d.ts` export lists are byte-identical to the
128
+ ones the audit signed off, and the version stays 0.16.0.
129
+
130
+ - **A gate's app-open counter stalled forever on a screen that never unmounts.** `bumpSessionCount`
131
+ follows real app-opens correctly and always has — but the review and questionnaire gates reached it
132
+ only from a `useState` initializer, which runs once per component INSTANCE. On a home feed that
133
+ stays mounted that is once per JS PROCESS, and iOS suspends apps rather than killing them: a user
134
+ who opens the app daily produces a fresh `app.session_started` each time (the SERVER's
135
+ `min_sessions` advances), while `wire_review_<id>_sessions` on the device sat at its launch value
136
+ and the LOCAL `minSessions` rule became unsatisfiable for the life of the app. Silent, and in the
137
+ safe direction — a prompt that never shows — which is why it survived a fix that was recorded as
138
+ complete. Both gates now subscribe to the session registry and re-ask the counter when a genuinely
139
+ new open is registered. A notification naming an open already counted is a no-op by construction,
140
+ so the cold-start ordering the existing pin exists for is untouched. Asserted at the mounted hook,
141
+ not at the helper: a green unit test over a helper is not evidence about its caller.
142
+
143
+ - **`useAnalytics` handed the host a DISPOSED instance after a StrictMode remount (dev builds only).**
144
+ React's StrictMode runs a mount effect setup → cleanup → setup with **no render in between**. The
145
+ cleanup disposes the instance and clears the ref while the component is still mounted, and no
146
+ render comes to rebuild it — so the object the host is holding is disposed, and
147
+ `EventQueue.enqueue` returns early when disposed. Every `track()` on it was a silent no-op: no
148
+ error, no warning, `size()` stayed 0, until the host happened to re-render. The hook now returns a
149
+ stable facade that delegates to the live instance at CALL time, and the mount effect's setup
150
+ rebuilds what its own cleanup disposed. Production `<StrictMode>` is a passthrough, so no shipped
151
+ app lost an event — but dev is where a developer verifies that their analytics work at all.
152
+
153
+ The render harness gained a `strict` option so the next hook gets this coverage for free. ⚠️ It is
154
+ in the harness rather than at each call site because the double-invoke only fires when
155
+ `<StrictMode>` is the OUTERMOST element of the tree; nested one level down it silently does
156
+ nothing and the test passes over the defect it was written for.
157
+
158
+ - **Five awaited `fetch` calls carried no ceiling.** The review and questionnaire transports (submit
159
+ and decision, both modules) and `reportClientEventsOutcome` all awaited a bare `fetch`, unlike the
160
+ event queue and `wireDoctor`, which each carried their own. A server that accepts a connection and
161
+ then says nothing parked those callers for the PLATFORM default (~60s on iOS) — holding the gates'
162
+ post latch closed, which is exactly the window their unmount recovery net cannot re-post inside,
163
+ and leaving a host's `await wire.track()` pending. All five now go through one shared deadline
164
+ helper carrying the two mechanisms the feature fetch learned the hard way: an abort that tears the
165
+ socket down, and a race that guarantees the promise settles even on a runtime that ignores
166
+ `signal` once the body has started. The BODY read is inside the deadline, because a ceiling
167
+ cleared when the headers land is not a ceiling.
168
+
169
+ - **The kit's one `any` is gone.** `WireFeaturesProvider` reached its two global context slots
170
+ through `globalThis as any`, which made both contexts infer as `any` — so every Provider value in
171
+ that file, and every read of them, was type-unchecked. The slots are typed now; a wrong-shaped
172
+ Provider value is a compile error, which it was not before.
173
+
174
+ #### Guarded — a latent context split that has no bite today
175
+
176
+ `components/Illustration.tsx` and `icons/IconRegistry.tsx` hold their registries in a plain
177
+ `createContext` rather than the shared global slot the theme and feature contexts use. Measured:
178
+ there is no bite today — both modules are reached only from the root barrel, so exactly one bundle
179
+ holds one context object. But that is a property of the current import graph, not of the code: the
180
+ day a subpath surface renders an icon or an illustration, the module is inlined into that bundle too,
181
+ the provider writes one context, the consumer reads another, and the registry silently serves empty.
182
+ Changing a live context's load-time identity is a riskier edit than the latent defect right before an
183
+ immutable publish, so this ships as a **ratchet only**: a test that fails the moment either module
184
+ reaches a subpath bundle, naming the fix (park it in the shared slot) rather than allow-listing it.
185
+
186
+ #### Fixed — the consumer could not complete step 0
187
+
188
+ - **No shipped doc said where to get an `apiKey`.** All four said "the getwireai console" with no
189
+ URL, and offered `register_<app>.py` as the alternative. That script is in **no** `files` entry,
190
+ lives only in a private repo, and its only instances are named after this vendor's own apps, so a
191
+ customer could never run it. Step 0 of a first integration was unexecutable. `README.md`,
192
+ `AGENTS.md`, `INTEGRATION_PROMPT.md` and `llms.txt` now carry the real path, measured live on
193
+ 2026-08-20: sign up at <https://getwireai.com/signup>, which creates the account, the first app
194
+ and its key together and lands the reader inside the console already signed in; then read the key
195
+ and the server URL off that app's **Integrate** tab. `/console/register` is for the SECOND app
196
+ onwards, and `/login` is for coming back later, not for finishing signup.
197
+
198
+ A second pass the same day corrected three details of that path that the first one got wrong,
199
+ because a wrong recipe is a different defect from a missing one, not a smaller one. Signup asks
200
+ for an app name and an email and takes the password on the confirmation screen (the docs said
201
+ email and password up front); it already mints the first app and its key, so a reader following
202
+ the old step 3 registered a redundant second app; and the **Integrate** tab renders exactly ONE
203
+ copy field, the API key. The server URL is the `serverUrl` line of the init snippet beside it,
204
+ so "both as copy fields" was not what the page shows. Per-environment keys are real and are
205
+ revoked in that tab's **Keys and environments** section, which is what the docs now name
206
+ instead of a standalone Keys surface.
207
+
208
+ A third pass corrected the docs' account of that same section, and of the key itself. **The
209
+ console has no add-key control**: self-serve issuance is gated off behind a flag that has been
210
+ `false` in every commit of the component's history (the empty state literally reads "Contact
211
+ support to provision one"), so "add and revoke them here" and "the console issues one key per
212
+ environment" were both capabilities we do not have. The label vocabulary is real, revoking is
213
+ real, issuing is not; the docs now say exactly that and point at Support. The wording had been
214
+ taken from a STALE COMMENT in the console repo describing the control as available -- a reminder
215
+ that another repo's prose is not that repo's behaviour, and only the component is.
216
+
217
+ **And a security note in this package contradicted its own instructions.** A callout added in
218
+ the first pass told the reader to keep the `apiKey` server-side and never commit it, three lines
219
+ above the step that puts it in `EXPO_PUBLIC_WIREAI_API_KEY`, which Metro inlines into the shipped
220
+ bundle at build time; the agent brief has always said the opposite and correctly ("a tenant key,
221
+ safe in the bundle"). The tenant key is DESIGNED to ship inside the mobile binary. What it cannot
222
+ do, since a backend ruling on 2026-08-01, is authenticate a console session, which is the
223
+ privilege boundary that was actually closed. The callout now says that, and keeps the one piece
224
+ of advice that survives: read it from an env var rather than a committed literal. Two more claims were corrected on review, both of
225
+ them the same class as the ones they were correcting: the key is readable back on EVERY visit
226
+ (the tab renders it from the app record, and the product's own signup copy promises exactly
227
+ that), so nothing "shows its key once"; and the `/login` step is not part of signup at all,
228
+ because the session cookie comes back on the set-password response and the reader is already
229
+ inside the console.
230
+
231
+ - **`package.json` published three dead links.** `homepage`, `repository` and `bugs` all pointed at
232
+ `github.com/chohra-med/wireai-onboarding`, which answers **404** to anyone not signed in. npm
233
+ renders exactly those fields as the Homepage / Repository / Issues links. `bugs` is why this was
234
+ treated as CRITICAL rather than cosmetic: it told customers to report problems at an address they
235
+ cannot open, so a report is never rejected, it is simply never filed, and nobody finds out.
236
+ `homepage` is now the product site, `bugs` is the console's support form, and `repository` is
237
+ removed rather than pointing a closed-source package at a 404.
238
+
239
+ ⛔ **npm metadata is immutable per published version, so 0.15.0's three links are permanent.**
240
+ They are serving right now: `npm view @wireai/activation@0.15.0 homepage bugs repository` still
241
+ returns all three `wireai-onboarding` URLs, and `latest` is still 0.15.0. Nothing here repairs
242
+ the version a customer installs today. The fix reaches anyone only on the NEXT publish, which
243
+ is 0.15.1, and that is the whole reason it was worth catching before the irreversible step
244
+ rather than after it.
245
+
246
+ `bugs` also carries an **email**,
247
+ because the support form sits behind the console login wall: a paying customer gets there, but
248
+ someone still evaluating the package has no account, and their report would go the same silent
249
+ way as before. npm renders the address beside the URL, so both readers have a route.
250
+
251
+ - **A pinned tarball version in an install line, for the second release running.** The README said
252
+ `npm install ./wireai-activation-0.15.0.tgz` while publishing 0.15.1, so the documented command
253
+ fails with ENOENT; the Distribution section three screens down already had it right as `x.y.z`.
254
+ This same class was fixed earlier in this very release and was not gated then, which is exactly
255
+ why it came back one version later: a doc fix that does not become an executable check is half a
256
+ fix. The neighbouring `git+ssh` line pointed at a repo path that does not exist, and is now a
257
+ placeholder.
258
+
259
+ - **The "Backend coupling" section reads as an instruction but is a VENDOR operation** (it ends in a
260
+ deploy against a private backend). It now says so, and points at the console's Support section.
261
+
262
+ #### Added — the gates, so none of the above can recur
263
+
264
+ `test/canary/keyAcquisition.test.tsx`, eight assertions, every one mutation-checked by reverting
265
+ the fix and watching that one assertion go red:
266
+
267
+ 1. every shipped doc names the signup URL;
268
+ 2. no shipped doc offers `register_…` in a sentence that also mentions a key;
269
+ 3. every console path printed is one this package DOCUMENTS, checked against an allowlist. It
270
+ started as a shape check and could not fail: `new URL()` cannot reject a string the capture
271
+ regex has already anchored to `https://getwireai.com`, and the capture stopped at the `<` of
272
+ `<app-id>`, so the templated Integrate URL was only ever seen as far as `/console/`. A
273
+ one-character typo in the address a first integration depends on passed all six;
274
+ 4. every consumer-facing link (`homepage`, `bugs.url`, and `repository.url` if it ever returns)
275
+ is on an ALLOWLIST of origins measured public. This started life as a ban list naming the repo
276
+ path the dead links used, and a ban list tests the wrong predicate: measured with the API,
277
+ `wireai-onboarding` and `wireai-activation` are ONE repo behind a redirect, and both 404 for
278
+ the public for a single reason, which is that a public package was pointing at a PRIVATE repo.
279
+ Spelling was never the fault, so a ban list would have waved through a `repository` field
280
+ naming the repo correctly (verified: it did, 6/6 green) and is blind to any private repo added
281
+ later. An allowlist fails on all of them and asks for a measurement;
282
+ 5. `homepage` and `bugs.url` are PRESENT and on the product host. That is all it ever did, and
283
+ it used to be NAMED "…and `bugs` is reachable by a customer", which the code did not check and
284
+ which is not true: `/console/support` answers 307 to `/login?next=…`, so a stranger reaches a
285
+ login wall. Renamed to its behaviour, because an assertion whose name claims more than its body
286
+ is worse than no assertion: it is read as coverage;
287
+ 6. `bugs` carries a LOGIN-FREE contact route, and not an unmonitored `noreply` one. This is where
288
+ the reachability claim honestly lives. An email needs no account, so it is decidable offline;
289
+ whether a login-walled URL lets a stranger in is not. An address nobody reads would satisfy the
290
+ field and move the silent failure one layer down instead of closing it;
291
+ 7. the two allowlists above are PINNED by content and length, so widening one is a failing test
292
+ rather than a one-line config edit. An allowlist that can be widened in the same diff that
293
+ needs the widening is decorative, and this repo has already lost that round once (a permitted-
294
+ `await` allowlist was defeated by adding a bare `"await"` entry, green through two reviews).
295
+ Verified both ways: adding `https://github.com` to the origins lets a `repository` field
296
+ pointing at the private repo pass assertion 4, and adding the typo'd path lets the typo pass
297
+ assertion 3 -- and this assertion catches both;
298
+ 8. no shipped doc pins a concrete tarball version in an install command.
299
+
300
+ ⛔ These canaries are OFFLINE by design, and the file header now says why a reachability probe
301
+ would not help: `/console/support` returns a FINAL 200 after one redirect, on the login page, so a
302
+ "follow redirects, expect 200" check scores the login wall as a pass. Reachability is a release-time
303
+ preflight that inspects the final URL, not a unit test.
304
+
305
+ #### Fixed — three runtime defects the pre-publish audit found LAST (2026-08-20)
306
+
307
+ - **CRITICAL — the questionnaire's recovery net was INERT while its POST was in flight, so a
308
+ finished run was silently destroyed AND its permanent once-gate already spent.** This is the exact
309
+ defect 0.15.1 fixed in `reviews/ReviewGate.tsx` earlier in this same version, left uncorrected on
310
+ the second copy — the `correct-the-whole-set-not-n-minus-one` class. `postedRef` is taken
311
+ OPTIMISTICALLY and released only in the transport's `.then`, while `finish` auto-closes the modal
312
+ 1500ms after posting. On any network slower than that the gate unmounted mid-flight, the net's only
313
+ branch (`if (undelivered && !postedRef.current)`) found the latch CLOSED and no-opped, and the POST
314
+ later resolved `"unsent"` with nothing mounted to notice. The host's `onResolved` → `markResolved`
315
+ has already written the permanent `wire_questionnaire_<id>_<ver>_seen` key, so that user is never
316
+ asked again. The `ReviewGate` shape is ported verbatim: `recoverOnSettleRef` / `recoveredRef` /
317
+ `postOnceRef`, an in-flight branch in `postOnce`'s `.then`, and an in-flight branch in the unmount
318
+ net; recovery stays bounded to ONE extra post. The questionnaire's deliberate difference is kept —
319
+ it has NO abandonment payload to rescue, because a half-typed draft is not an answer.
320
+ (`src/questionnaire/QuestionnaireGate.tsx`.)
321
+
322
+ - **HIGH — a MINTED session id defeated the cold-start guard, so one launch counted as TWO
323
+ app-opens.** `ensureCurrentSessionId()` mints a fallback id AND writes the shared session slot (it
324
+ must: the server drops an event with no `session_id` behind a 200), and it is reached from
325
+ `analyticsFacade.resolveSessionId`, `wireActivation.track` and `reviews/transport.reportAppEvent` —
326
+ any of which a host can hit before `useLifecycleEvents` mounts. So `currentOpenId` pinned the launch
327
+ with `observed = <the minted id>` instead of `undefined`, its cold-start adopt branch never fired,
328
+ and the REAL `app.session_started` that followed read as a genuinely new open. The fail-closed
329
+ `minSessions: 2` default — the guard added after the 2026-07-16 one-star incident — became
330
+ satisfiable inside a user's FIRST session. It failed in the UNSAFE direction. A fallback id is not
331
+ evidence the app was opened: the mint is now recorded in its own cross-bundle slot and
332
+ `currentOpenId` treats a pin taken over one exactly like a pin taken over nothing, so the first
333
+ REAL registration adopts the open rather than starting a second. The existing cold-start rule is
334
+ unchanged. (`src/analytics/currentSession.ts`, `src/reviews/runtime.ts`.)
335
+
336
+ - **MEDIUM — `fetchWireFeatures` had no ceiling on the BODY read, so both gates could hold dark
337
+ forever.** The abort timer was cleared in a `finally` around the `fetch` alone, so it died the
338
+ moment the headers landed and `await res.json()` ran uncapped. A 200 whose body then stalls left
339
+ the call pending forever, `settled` never flipped, and `useReviewGate` / `useQuestionnaireGate`
340
+ never became ready — inverting the module's own stated contract, *"fail-open, never fail-dark… it
341
+ can never dark a tenant's activation"*. One deadline now covers the headers AND the body, with two
342
+ mechanisms because they fail differently: the abort tears the socket down, and the race guarantees
343
+ the promise settles even on a runtime that ignores `signal` mid-body. Honest scope: on native RN
344
+ `fetch` is the XHR polyfill and the body is already buffered, so this was effectively unreachable
345
+ there; on RN-Web / Expo web, which the kit explicitly supports, it is real.
346
+ (`src/features/fetchWireFeatures.ts`.)
347
+
348
+ Every one of the three carries a new test that was MUTATION-CHECKED (revert the fix → that test goes
349
+ red → restore, tree byte-identical), and the two whose assertion is "a number did NOT move" carry an
350
+ instrument check proving the harness can see the move it is asserting the absence of.
351
+
352
+ #### Checked and clean (no change needed)
353
+
354
+ The tarball leaks no internal path (`.memory`, `ai_rules`, `CLAUDE.md`, specs, tests: **0**), ships
355
+ no credential-shaped literal and no commit SHA in the release notes; **43/43** `exports` targets and
356
+ **19/19** type entrypoints exist in the tarball; the README's install line resolves in a clean
357
+ consumer with no peer errors; **all 7 subpaths** and **all 28 documented named imports** resolve
358
+ against the *installed* package; and the README's `metro` snippets execute correctly in both their
359
+ documented call forms. Each of those instruments was positive-controlled before its result was
360
+ believed.
361
+
362
+ Tests: **912 → 920**, 0 fail; `mcp` **128**, 0 fail; typecheck and a clean build both green.
363
+
364
+ ⚠️ **The console claims in these docs are NOT gated, and cannot be from here.** They are assertions
365
+ about another repository's rendered UI; this package's canaries are offline and in-tree. Three
366
+ rounds of review found a false console claim each time, so the standing rule is that every sentence
367
+ about the console names the `file:line` in the console repo that proves it, and the durable fix is a
368
+ release-time preflight that reads that repo. Filed, not built here.
369
+
370
+ ### The docs-and-gates pass this version started as
371
+
372
+ 0.15.0 shipped `<WireOnboarding config={{ ...config, storage }} />` in two published docs and in the
373
+ `userContext` JSDoc that seeded them. `storage` is a PROP of `<WireOnboarding>`, not a field of
374
+ `WireOnboardingConfig`, so an agent that copied that line shipped an integration that does not
375
+ compile — and, if it worked around the error by dropping the prop, one with a permanently empty
376
+ `activated` funnel and no error anywhere. The root cause was not the typo. It was that NOTHING in
377
+ this repo has ever compiled doc-borne code: `src/docsContract.test.ts` reads the `import` line of an
378
+ example and nothing below it, and everything else polices the docs as prose. `docsContract.test.ts`
379
+ had already written the verdict in its own header — *"prose review has already failed to catch it
380
+ twice. Only an executable check holds."*
381
+
382
+ ### Added
383
+
384
+ - **`test/canary/docExamples.test.tsx` — the doc examples are now COMPILED.** It resolves the doc
385
+ list off the real publish surface (never a filename list), extracts every fenced ` ```ts ` /
386
+ ` ```tsx ` block, writes each to its own file, and runs `tsc --noEmit` with
387
+ `@wireai/activation*` mapped to the BUILT `dist/*.d.ts` — the types a customer installs, not
388
+ `src/`. 29 blocks compile today. Free identifiers are supplied in three tiers: the handful the
389
+ kit itself types (`config`, `storage`, `target`, the id strings), then every name the built
390
+ bundles export bound to its real shipped type, then everything still unresolved declared `any` as
391
+ a host-owned value — the last tier discovered mechanically from a compile pass, not from a list
392
+ that would rot. A fence that is genuinely a fragment opts out with
393
+ `<!-- doc-check: skip - reason -->`, and a skip with no reason fails the suite.
394
+ - **Three policy assertions a compiler cannot make.** Every documented `npm install` line must name
395
+ every NON-OPTIONAL peer, read from `peerDependencies` + `peerDependenciesMeta` at test time.
396
+ Every PRIMARY `<WireOnboarding>` example (one that shows `config` and BOTH exits) must carry
397
+ `fallbackFlow` and a join key, because both props are optional in the type and only a policy can
398
+ see their absence. And no doc may tell a reader to run something the tarball does not ship.
399
+ - **Five anti-false-clean floors,** one per scanner: the doc list, the fence count, the parsed
400
+ export surface, the install-line count and the primary-example count. Measured, then asserted, so
401
+ a rotted regex fails instead of passing over nothing.
402
+
403
+ ### Fixed
404
+
405
+ - **`storage` was documented as a config field.** Corrected in `AGENTS.md` (the join-key example and
406
+ the lifecycle paragraph), `llms.txt`, `INTEGRATION_PROMPT.md`, and — the origin the copies came
407
+ from — the `userContext` JSDoc in `src/types.ts`. `README.md` was already right.
408
+ - **`useLifecycleEvents(config, …)` did not compile as documented.** Its first argument is a
409
+ `LifecycleConfig`; `wireConfigFromEnv` returns `WireOnboardingConfig | null`. The three agent docs
410
+ now print the literal form `useLifecycleEvents({ serverUrl, apiKey, appId, storage }, …)` that
411
+ `README.md` already used, and say why.
412
+ - **`fetchReviewDecision` → `useReviewGate` dropped the `?? undefined`** in `AGENTS.md`. The fetch
413
+ resolves `ReviewDecisionResponse | null`; the hook option is `decision?: ReviewDecision`.
414
+ - **Install lines left out required peers.** `zod` and `react-native-safe-area-context` are
415
+ non-optional and were missing from every install line in `README.md`, `AGENTS.md`, `llms.txt` and
416
+ `INTEGRATION_PROMPT.md`.
417
+ - **The two primary README examples taught an integration the review bar rejects** — no
418
+ `fallbackFlow` (so a backend error dead-ends instead of degrading) and no join key (so the
419
+ `activated` funnel reads zero, silently). Both now carry them.
420
+ - **Every doc led with a "fastest path" that does not exist.** The `wire-rn-integration` skill is
421
+ not in the tarball and is not in this repo either. Replaced with `INTEGRATION_PROMPT.md`, which
422
+ ships in the package.
423
+ - **Six more examples that did not compile:** a `useRef<string>()` with no argument (React 19
424
+ removed that overload), an untyped `useMemo` whose widened `gesture: string` cannot be a
425
+ `CoachmarkStep`, a `<WireOnboarding>` missing its required `config`, and the review-gate snippets
426
+ that reused the names `config` and `storage` for the GATE's config and its SYNCHRONOUS store.
427
+
428
+ ### Changed
429
+
430
+ - **`prepublishOnly` runs the whole suite, not just the canaries.** It was
431
+ `npm run build && npm run test:canary`, which excludes `src/**/*.test.ts` — so
432
+ `src/docsContract.test.ts`, the one doc gate that already existed, never ran at publish time. It
433
+ is now `npm run build && npm test`, the same suite CI and a developer run.
434
+
435
+ ### The doc gate had a silent-skip loophole (second pass, same day)
436
+
437
+ > A separate pass over the gate the section above added. It kept a hole of exactly the shape the
438
+ > gate exists to close, and four live doc defects were sitting inside it.
439
+
440
+ - **A BARE ` ``` ` fence was skipped SILENTLY.** The extractor matched only ` ```ts ` / ` ```tsx `;
441
+ anything else was dropped with no marker, no reason and no failure. That is the inverse of the
442
+ `doc-check: skip` directive, whose reason is MANDATORY. The consequence was measurable:
443
+ `INTEGRATION_PROMPT.md` had zero tagged fences, so the gate compiled **nothing** from the file
444
+ every other doc calls the fastest path, and `llms.txt` prints no fence at all. Every fence in a
445
+ shipped doc now needs an explicit DISPOSITION: `ts`/`tsx`, a known code/data lane, or the skip
446
+ directive with a reason. A bare fence fails by construction, so no heuristic ever has to guess
447
+ whether to fail. (For the record, because the miscount is easy to repeat: counting untagged
448
+ ` ``` ` LINES reports 42 for `README.md`, but 41 of those are the CLOSING fences of tagged
449
+ blocks. Across all five docs exactly TWO blocks were opened by a bare fence, an env file and an
450
+ English prompt, and neither was TypeScript.)
451
+ - **A language tag can no longer hide a TypeScript example.** A ` ```bash ` or ` ```json ` fence is
452
+ now content-checked for line-anchored TS/TSX shapes, and a ` ```js ` fence may be CommonJS (the
453
+ metro config) but not ESM or JSX. An unknown tag fails loud rather than passing quietly. The
454
+ shape detector has a positive control that runs as a test: it must fire on a real TSX example and
455
+ stay silent on a shell transcript, an env file, and prose that quotes code in backticks.
456
+ - **Inline `code spans` in prose are now compiled too, as far as they honestly can be.** A span is
457
+ lifted into the compiler when it is a complete call or self-closing JSX element, headed by a name
458
+ the kit exports, carrying an object literal (which is what separates a call a reader TRANSCRIBES
459
+ from signature notation like `track(name, meta?)`). 34 spans compile today, which is the only
460
+ coverage `llms.txt` and `INTEGRATION_PROMPT.md` have ever had. Stated plainly: this is PARTIAL.
461
+ Signature notation is not code and is not checked, and a bare identifier in prose cannot be told
462
+ apart from a host-owned name, so prose identifiers are not existence-checked.
463
+ - **A whole-screen scaffold is now held to the review bar.** The `PRIMARY` selector keys off
464
+ `config` + BOTH exits, and `README.md`'s "3-line minimal usage" slipped through it for the whole
465
+ 0.15.0 cycle by simply not printing `onSkip`: a complete, exported, copy-me screen with no
466
+ `fallbackFlow` and no join key, exempt BY CONSTRUCTION. A fenced example that imports from the
467
+ kit, declares a component and mounts `<WireOnboarding>` is now held to the same bar whatever
468
+ props it prints.
469
+
470
+ ### Fixed by the tightened gate
471
+
472
+ - **`INTEGRATION_PROMPT.md:66` taught `useReviewGate({ config, decision })`, which does not
473
+ compile** on either argument: `Type 'ReviewDecisionResponse | null' is not assignable to type
474
+ 'ReviewDecision | undefined'`, and `Property 'id' is missing in type 'WireOnboardingConfig' but
475
+ required in type 'ReviewConfig'`. `AGENTS.md` already called the `?? undefined` "not optional"
476
+ and `README.md` already showed it correctly; the prompt was the copy nobody compiled.
477
+ - **Two docs handed `wireConfigFromEnv(...)` straight to the required `config` prop.** It returns
478
+ `WireOnboardingConfig | null`. `INTEGRATION_PROMPT.md` and `llms.txt` now mint it, gate on the
479
+ null, and render the gated value.
480
+ - **The README "minimal usage" example had no `fallbackFlow`, no `storage` and no `userContext`.**
481
+ It now carries them, and says why: those are the two omissions that fail silently.
482
+ - **`isOnboardingEnabled` was documented with only its `remote` option** in `AGENTS.md` and
483
+ `llms.txt`; it also takes `config` (`WireConfigOverrides`).
484
+ - **The `AGENTS.md` "Types:" line omitted `WireOnboardingStorage` and `LifecycleConfig`,** both
485
+ exported and both needed to type the props that same file documents.
486
+
487
+ ### Documented (three silent failures the agent docs never named)
488
+
489
+ - **The review/questionnaire gates need SYNCHRONOUS storage.** `useReviewGate` counts app opens
490
+ through a `CoachmarkStorage` (`getItem` returns `string | null`, not a Promise), which is a
491
+ DIFFERENT shape from the AsyncStorage-style adapter `<WireOnboarding storage>` takes. The kit's
492
+ own source says the outcome: "No storage pins the counter at 1 forever, so the fail-closed
493
+ minSessions rule can never be met and the gate silently never fires." An async adapter reaches
494
+ the same dead end: the kit probes it, sees the thenable, and substitutes a fail-closed store.
495
+ `README.md` documented this; `AGENTS.md`, `llms.txt` and `INTEGRATION_PROMPT.md` did not mention
496
+ `storage` at all, while `AGENTS.md` opens by claiming "everything you need is here". Now in all
497
+ three.
498
+ - **How to actually WIRE `<ReviewGate>`.** It has no `gate` prop. `AGENTS.md` gains a compiling
499
+ example (gated by the harness above) showing the controller's `visible` / `markShown` /
500
+ `markResolved`, and spells out that omitting `onShown`/`onResolved` compiles, shows the prompt,
501
+ and re-prompts the same user on every open, because those two are what persist the cooldown and
502
+ the once-per-version seen gate. They previously appeared only in `README.md`.
503
+ - **`sessionCount` is a HOST obligation.** `AGENTS.md`, `llms.txt` and `INTEGRATION_PROMPT.md`
504
+ printed `{ deviceKey, sessionCount, userId }` without saying the host must own a persisted,
505
+ per-open counter. A hardcoded `1` compiles and pins every user at `returning: false` forever.
506
+ - **`wireDoctor` cannot see the #1 documented silent failure, and the docs implied it could.** Its
507
+ four checks are `target`, `reachability`, `storage`, `round_trip`. None checks the join key, the
508
+ lifecycle mount, or `min_sessions`, so an integration with a permanently-zero `activated` funnel
509
+ passes every check green. `INTEGRATION_PROMPT.md` said "Every check must come back `ok`" and
510
+ stopped there. All three agent docs now state what a green report does NOT mean. **No runtime
511
+ change**: `src/analytics/wireDoctor.ts` is untouched. A `join_key` check is recommended as future
512
+ work, not implemented here.
513
+
514
+ ### Still blocking (not invented)
515
+
516
+ - **No doc can tell a reader where to GET an api key or a `serverUrl`.** Every one says "the
517
+ getwireai console" with no URL, and no `register_<app>.py` ships in the tarball. Deliberately
518
+ left alone rather than filled with a guessed URL. It needs an owner decision.
519
+
520
+ ### Fixed — five silent runtime failures (audit wave, branch `fix/0.15.1-runtime-silent-failures`)
521
+
522
+ *A separate set of changes inside the same version. Every one of these compiles, runs, looks fine
523
+ and reports nothing — the product's entire failure history in one shape. Each carries a test that
524
+ was watched go RED with the fix reverted and GREEN with it restored.*
525
+
526
+ - **A hung storage adapter killed the whole app-open funnel.** Both lifecycle hooks
527
+ (`useLifecycleEvents`, `useSessionStart`) awaited `hydrateDeviceIdentity`, which awaits a BARE
528
+ `storage.getItem` with no ceiling anywhere in the chain, and every fire lives inside that `.then`.
529
+ An adapter that neither resolves nor rejects (a locked keychain, a wedged bridge) therefore meant
530
+ `app.session_started` and `app.first_open` never fired — for the whole process, on every launch,
531
+ with nothing thrown and nothing logged. Those are the events the server counts `min_sessions`
532
+ from, so the counter stayed 0 and every review / questionnaire trigger that reads it became
533
+ permanently unsatisfiable. The kit had already solved this five times (`WireOnboarding.tsx`'s
534
+ `AUTO_JOIN_HYDRATION_TIMEOUT_MS`, `persistedSession`, `lifecycle`, `eventQueue`, `features/cache`);
535
+ only these two hooks had no cap. Both now race the read against the SAME exported ceiling
536
+ (`withTimeout` + `READ_TIMEOUT_MS` from `session/persistedSession`) and fire DEGRADED on a
537
+ timeout — with no auto device key, exactly the branch a rejecting adapter already took. The false
538
+ JSDoc on `hydrateAutoDeviceKey` / `hydrateDeviceIdentity` that claimed a *"hung"* adapter resolves
539
+ to the in-memory id is corrected: it never did, and now says so and names the ceilings its callers
540
+ apply. (`app.first_open` is still not EMITTED under a hung adapter — `reportFirstOpen` declines on
541
+ its own timed-out flag read, a standing 0.14.0 ruling — but it is now REACHED instead of stranded.)
542
+ - **`reportFirstOpen` spent the once-EVER flag when nothing was sent.** With `storage` + `appId` but
543
+ no `sink` and no `serverUrl`, `emitFirstOpen` routes through `buildEventsRequest`, which returns
544
+ `null` — a silent no-op — and the persisted `wireai:first_open:<appId>` flag was written anyway.
545
+ That marks the install as having reported its first open while ZERO events exist, and the flag
546
+ survives app kills, so `app.first_open` was dead for that install forever. Reachable on the
547
+ documented wiring: `serverUrl` is optional on `LifecycleConfig` and the hook's effect is
548
+ mount-only, so a late-resolving URL lands exactly here. It now bails on
549
+ `!sink && !target?.serverUrl` before either guard, mirroring `reportSessionStart`.
550
+ - **An EMPTY `steps` array burned the coachmark once-gate.** `useCoachmarkTour` armed without
551
+ looking at `steps`, then the drive effect hit `activeIndex >= steps.length` → `finish()` →
552
+ `markSeenGate(...)`: the user saw nothing and the tour was dead on that install for good, even
553
+ after the catalog was fixed. Reachable on the documented AI path — `selectTourSteps` returns `[]`
554
+ whenever the server sends ids a SHIPPED catalog does not know — and on any host whose steps
555
+ arrive async. The tour no longer arms on an empty list (keyed on `steps.length`, a VALUE, so an
556
+ inline array cannot restart the arm timer every render), and a list that goes empty mid-tour takes
557
+ the kill switch's `exit(false)`, which writes no gate.
558
+ - **The review abandonment net was inert while the post was in flight.** `postedRef` is taken
559
+ optimistically and released only in `submitReview`'s `.then`, while the gate auto-closes 1500ms
560
+ after posting. On any network slower than that, unmount ran with the latch still closed: the
561
+ recovery branch needs `!postedRef.current` and the abandonment branch returns on it, so BOTH
562
+ no-opped and a detractor's 1-4 stars plus mandatory text was dropped silently — and the once-gate
563
+ means they are never asked again. The unmount now hands the recovery to the promise when it finds
564
+ a post still in flight, and the `.then` re-posts once if nothing reached the server. Bounded to
565
+ exactly ONE extra post (the net's existing in-process ceiling), carrying the impression's
566
+ `idempotency_key` and its unit, so a row that did land is upserted rather than duplicated.
567
+ - **The review kill switch could not stop the FIRST impression.** `useWireFeatures` seeds the all-on
568
+ defaults and swaps only when `GET /v1/features` resolves, so `useReviewGate` read
569
+ `review.enabled === true` on the first render of every mount — and with no `timeoutFallbackMs`
570
+ the local wait is skipped too. A tenant with `review.enabled: false` still got a visible gate, a
571
+ `review_prompt_shown` and, if tapped in that window, a POSTed row; the code comment saying such a
572
+ tenant "must never see the gate" was falsified by its own async default. The gate now holds its
573
+ first impression until the feature answer is SETTLED. Fail-open is untouched, and the distinction
574
+ is the whole fix: `fetchWireFeatures` swallows a timeout / 401 / 5xx into the defaults and that
575
+ resolution IS an answer, so an unreachable control plane still allows the gate — only an
576
+ unanswered question holds it, and a surface with no `featuresConfig` has nothing to wait for.
577
+ - **Internal, no public surface change:** `useWireFeaturesState` / `useResolvedFeaturesState` (flags
578
+ + `settled`) back the two existing flags-only hooks, and `WireFeaturesProvider` publishes the
579
+ settled flag on a SECOND `Symbol.for` context (`wireai.features.settled.context`, defaulting to
580
+ `true` so a missing or older provider can never suppress a gate). Neither is exported from the
581
+ root barrel — this stays a PATCH.
582
+ - **15 new tests** (878 → 893 after rebasing onto #100's +9; mcp unchanged at 128), each
583
+ mutation-checked:
584
+ `test/canary/lifecycleHungStorage.test.tsx`, `test/canary/coachmarkEmptyTour.test.tsx`,
585
+ `test/canary/reviewKillSwitchRace.test.tsx`, three slow-network cases added to
586
+ `test/canary/reviewSubmissionDurability.test.tsx` (the existing fast-path ordering is untouched —
587
+ it is what hid the defect), and one no-transport case in `src/session-analytics/lifecycle.test.ts`.
588
+
589
+ ### Fixed — the n−1 twin: the QUESTIONNAIRE kill switch had the identical first-render defect
590
+
591
+ - **`useQuestionnaireGate` read `.questionnaire.enabled` off the optimistic all-on seed**, exactly
592
+ as `useReviewGate` did before the fix above. A tenant with `questionnaire.enabled: false` could
593
+ therefore still get a visible gate on the first render of every mount, a
594
+ `questionnaire_prompt_shown`, and — if the user answered inside that window — a POSTed response
595
+ row on `/v1/questionnaires/{id}/responses`. It was filed as a deliberate follow-up when the review
596
+ gate was fixed (`.memory/60-decisions.md`, 2026-08-19) rather than bundled, so a falsification
597
+ failure there could not be ambiguous between two surfaces.
598
+ - **The fix reuses the seam that fix introduced — no second mechanism.** The gate now reads
599
+ `useResolvedFeaturesState` and holds its first impression until `settled`, and `settled` flips on
600
+ EVERY resolution of `fetchWireFeatures` including the fail-open one. An unreachable control plane
601
+ (timeout / 401 / 5xx / malformed body) resolves to the all-on defaults, and that resolution IS the
602
+ answer, so it still ALLOWS the questionnaire; only an unanswered question holds it. A host with no
603
+ `featuresConfig` has nothing to wait for and is byte-identical to before.
604
+ - **No public surface change.** `useResolvedFeaturesState` stays internal, nothing is exported,
605
+ renamed or removed from any barrel — this remains a PATCH.
606
+ - **5 new tests** (893 → 898; mcp unchanged at 128), all in
607
+ `test/canary/questionnaireKillSwitchRace.test.tsx`, mutation-checked in BOTH directions: removing
608
+ the settled seam turns 4 of them red, and breaking fail-open (settling only on a non-default
609
+ answer) turns the other 3 red. Every fail-open test asserts the HOLD before the RELEASE — a
610
+ release-only assertion passes on an unfixed gate and would certify the defect.
611
+
612
+ ### Fixed — the n−2 and n−3 twins: the COACHMARKS and SHOWCASE kill switches, where the same seam does NOT port
613
+
614
+ - **The same first-render defect, on the last two surfaces that had it.** `CoachmarkProvider` read
615
+ `.coachmarks.enabled`, and `FeatureShowcase` read `.showcase.enabled`, off the optimistic all-on
616
+ seed. A tenant who switched either module OFF still got the first tour, and the whole full-screen
617
+ intro, on every cold start — and the showcase could take the once-gate with it: tapping through
618
+ those slides calls `finish`, which writes `wire_showcase_<id>_seen`, so the showcase then never
619
+ replayed even after the tenant re-enabled it. Silent, permanent, once per install.
620
+ - **⛔ The seam the review + questionnaire gates use does NOT port to either surface, and forcing it
621
+ would have shipped a worse defect than the one being fixed.** Both sites were left alone in the
622
+ earlier pass for that reason, correctly. Each needed its own shape:
623
+ - **Coachmarks — TRI-STATE, because the kill switch is TERMINAL.** `useCoachmarkTour` SUBSCRIBES to
624
+ the coachmarks flag and `exit()`s a tour in flight when it flips false. Holding the surface by
625
+ writing that flag false during the fetch would therefore have ended the tour of every ENABLED
626
+ tenant on every cold start. So "is the verdict an ANSWER?" is now a SECOND fact on the same
627
+ runtime record (`setCoachmarksResolved` / `areCoachmarksResolved`, written by `CoachmarkProvider`
628
+ from the resolved features' `settled`, waking the same listener set). An unanswered verdict blocks
629
+ ONLY the ARM of a NEW tour — no timer starts, no overlay shows, no once-gate is consumed, so an ON
630
+ answer plays the tour in full — and never touches a tour already running. The field is OPTIONAL on
631
+ the runtime type and reads `true` when absent, exactly like `listeners`, so a record left by an
632
+ inlined copy from an older kit version is upgraded in place rather than read as "unanswered".
633
+ - **Showcase — a BOUNDED, SCOPED hold, because the disabled path calls `onDone`.** A naive hold on
634
+ `!settled` is a blank full screen for as long as the features fetch takes (its own ceiling is
635
+ 4000ms), on every launch. Two bounds fix that. SCOPE: an already-seen gate or an absent pager
636
+ skips whatever the tenant answers, so those launches — every launch after the first — never wait
637
+ at all. TIME: the hold expires at `READ_TIMEOUT_MS` (1500ms, the repo's one exported read ceiling,
638
+ reused rather than a third number invented here) and falls through to the previous optimistic
639
+ behaviour, so the worst case is what shipped before, reached later.
640
+ - **Fail-open is unchanged on both.** `settled` flips on EVERY resolution of `fetchWireFeatures`
641
+ including the fail-open one, so a timeout / 401 / 5xx / malformed body still arms the tour and
642
+ still plays the showcase. Only a question nobody has answered YET holds either surface, and a host
643
+ with no `featuresConfig` has nothing to wait for and is byte-identical to before.
644
+ - **No public surface change.** `useResolvedFeaturesState`, `setCoachmarksResolved` and
645
+ `areCoachmarksResolved` are all internal — `src/coachmarks/index.ts`, every other barrel and
646
+ `package.json` are byte-identical to the previous commit. This remains a PATCH.
647
+ - **11 new tests** (898 → 909; mcp unchanged at 128): 5 in
648
+ `test/canary/coachmarkKillSwitchRace.test.tsx`, 6 in
649
+ `test/canary/showcaseKillSwitchRace.test.tsx`. Both files were mutation-checked — reverting each
650
+ fix turns its first test red — and each carries the controls that stop the fix from being a mute:
651
+ an explicit ON must still fire, a FAILED fetch must still fire, and a host with no `featuresConfig`
652
+ must be unchanged. The coachmark file additionally pins THE LINE THE FIX MUST NOT CROSS: an
653
+ unanswered verdict may not tear down a tour in flight. The showcase file pins both bounds of its
654
+ hold, including that an already-seen showcase is never held at all.
655
+
656
+ ### Fixed — the review gate's ambient device key could go SILENTLY DEAD for the whole process
657
+
658
+ - **`ambientAutoDeviceKey` minted its fallback id INTO the tenant census it then counts.** The
659
+ function answers "which single id space does this process have?" by reading `registry.keys`, and
660
+ refuses (`undefined`) when it reads two, because a propless caller cannot say which of two tenants
661
+ a device belongs to. When it read ZERO it minted through `resolveAutoDeviceKey()`, which
662
+ REGISTERED that fallback under `"default"` — so the fallback became an entry in the very census
663
+ the next call is judged by. The review gate lives on the home feed, so a cold start can reach it
664
+ before any Wire surface has initialised: the gate asked first, left a `"default"` entry behind,
665
+ and the moment the host's real `appId` registered the census read TWO. Every later call returned
666
+ `undefined`, the injected `meta.device_key` stopped happening, and the server then has no unit to
667
+ scope the impression's `idempotency_key` to — so it discards the key and inserts a plain row,
668
+ which means each recovery re-post writes a SECOND review row (a double-counted `count`, a
669
+ corrupted `avg`). Ordering-dependent, silent, and permanent for the process.
670
+ - **The fix holds the fallback OUTSIDE the census** (a new `ambient` field on the existing
671
+ `@wireai/activation:autoDeviceKeys` registry, not a new global slot), so it yields instead of
672
+ competing: a real id space appearing later simply wins, which is the answer the function's own
673
+ docstring already argued for. A process that never registers one keeps its fallback, so the
674
+ reviews-only host is byte-identical to before.
675
+ - **The docstring was amended rather than left standing.** Its three-state reasoning was sound at
676
+ any ONE instant and wrong across two, because the empty-state branch wrote into the map the next
677
+ instant is read from; the comment now says so.
678
+ - **Deliberately NOT changed: the impression's unit is still pinned even when it is NON-DURABLE.**
679
+ Porting the kit's cross-launch-join rule ("read `durable`, refuse a `false`") to this field was
680
+ considered and REJECTED, with the reasoning recorded in `src/reviews/idempotency.ts` and
681
+ `.memory/60-decisions.md`. Refusing a non-durable unit removes the unit, which makes the server
682
+ discard the key and write the second row — trading an under-counted join for a corrupted rating.
683
+ Measured, not argued: applying that rule turns 7 existing tests red, including
684
+ `THE INERTNESS PROBE`, both `CONTRACT SIM`s and `ASYNC ORDERING`, every one of which exists to
685
+ pin that a home-feed post carries a unit.
686
+ - **What this release does NOT close, stated plainly.** A review row can still be stamped with a
687
+ device key no analytics event carries, in two sequences: an impression opened before any surface
688
+ has registered a real `appId` (it pins the ambient fallback), and one opened while a hydration is
689
+ in flight (it pins the synchronous mint, and the persisted id replaces it a moment later). What
690
+ the fix above removes is the PERMANENT case — the reader answering `undefined` for the rest of the
691
+ process — so the residue is bounded to impressions opened before the id space settles, and the
692
+ gate is once-per-user. Refusing the unit does not repair that join; it removes the join AND the
693
+ server's ability to dedupe. Closing it properly means giving the gate a settled identity to pin
694
+ (`appId` / `storage` props on `ReviewGate`), which is a public-API change and not a PATCH.
695
+ - **No public surface change.** `ambientAutoDeviceKey` is internal (it is not in `src/index.ts`);
696
+ no barrel and no `package.json` field moved — this remains a PATCH.
697
+ - **Also added: the guard for the coachmark ruling in the section above, which had none.** That
698
+ section argues at length that an UNANSWERED verdict must publish the kill switch as ON, because
699
+ the flag is terminal for a tour in flight — and flipping that `true` to `false` left all five of
700
+ its tests green. The one that names the shape ("an UNANSWERED verdict never tears down a tour in
701
+ flight") reaches it by calling `setCoachmarksResolved(false)` on the runtime singleton and never
702
+ mounts a `CoachmarkProvider`, so the line encoding the ruling was executed by no test. The new
703
+ case arms a tour, then mounts the REAL provider with `/v1/features` deliberately unanswered, and
704
+ asserts the tour survives; it is the only test in the file the naive repair turns red. No source
705
+ change — `src/coachmarks/CoachmarkProvider.tsx` is byte-identical.
706
+ - **3 new tests** (909 → 912; mcp unchanged at 128), each mutation-checked by reverting the code it
707
+ guards and watching it fail:
708
+ `src/reviews/idempotency.test.ts` ("ambientAutoDeviceKey SURVIVES a late registration") and
709
+ `test/canary/idempotencyKey.test.tsx` ("REGISTRATION ORDER"), the latter driving the real
710
+ `ReviewGate` and asserting on the CAPTURED POST BODY, never on a return value; plus
711
+ `test/canary/coachmarkKillSwitchRace.test.tsx` ("THE SAME LINE, THROUGH THE REAL PROVIDER").
712
+
713
+ ## [0.15.0] - 2026-08-19
714
+
715
+ **MINOR, not patch, and only because of new API. Two exported surfaces each GAINED an optional
716
+ member; nothing was lost or renamed.** `ReviewSubmission` gains an OPTIONAL `idempotency_key` and
717
+ `buildReviewSubmission` gains an OPTIONAL `idempotencyKey` input. Both are additive and optional, so
718
+ every existing call site compiles unchanged and sends a byte-identical body unless it opts in. A
719
+ gained public member is still a MINOR under this changelog's own precedent: 0.14.0 was bumped MINOR
720
+ for exactly this shape, an optional field added to an exported interface. Every behaviour change
721
+ below is a bug fix.
722
+
723
+ ### Added
724
+
725
+ - **The review gate can now reach the server's idempotent upsert path, so a recovery post merges
726
+ into the row it may already have written instead of adding a second one.** `POST /v1/reviews`
727
+ accepts an optional client-minted `idempotency_key` and derives the row id from it; the kit now
728
+ mints one per gate IMPRESSION and holds it across every post of that impression. This is a
729
+ CAPABILITY, not a guarantee, and the condition is load-bearing: the server honours the key only
730
+ when the post also carries a unit it can scope it to, and ignores it outright otherwise.
731
+ - **Every review post now carries that unit.** The server resolves it as `meta.device_key`, else
732
+ `session_id`. The gate lives on the home feed, where a user legitimately has no onboarding
733
+ session, and the kit never put a `device_key` into a review `meta` — so the key alone would have
734
+ been discarded on the default wiring. The gate now fills in the kit's own per-install auto id,
735
+ and only when the host supplied none: **any non-empty string you pass in `meta.device_key` wins
736
+ verbatim.** The kit counts a value as host-supplied only when it is a string with at least one
737
+ non-whitespace character, so a non-string or an all-whitespace one is not a usable unit and is
738
+ replaced. **Overriding `meta` cannot remove the unit**: the injection tests the MERGED bucket, so
739
+ stripping `device_key` there only gets the auto id back. Exactly one post carries no unit at all,
740
+ the one where the kit cannot name an id honestly: two tenants registered in one process, which
741
+ leaves the per-install id space ambiguous and the auto id `undefined`. That post reaches the
742
+ server with no unit, the server ignores the idempotency key, and that path keeps the previous
743
+ behaviour.
744
+ - The injected id is the SAME per-install id the analytics surfaces use, adopted from the process
745
+ registry rather than minted fresh, so the gate never opens a second id space for one install.
746
+
747
+ ### Unchanged on purpose
748
+
749
+ - **The `rejected` verdict is still not retried.** Re-posting after a non-2xx is now safe wherever
750
+ the key is honoured, but it would also turn a genuine 4xx refusal into a retry needing its own
751
+ budget. That is a separate recovery decision and is deliberately not bundled here.
752
+ - Questionnaire submissions are untouched. The server has no idempotency path for them, so every
753
+ statement in `src/questionnaire/` about there being no key on the wire remains true.
754
+
755
+ ### Fixed
756
+
757
+ - **An ASYNC gate-storage adapter now fails CLOSED instead of misfiring the gates.** The sync gate
758
+ storage (`CoachmarkStorage`) silently accepted an async adapter — AsyncStorage handed straight to
759
+ `CoachmarkProvider` or a gate's `storage` prop. Its `getItem` returns a Promise, which is never
760
+ `=== "1"` and parses to `NaN`, so every seen-gate read "unseen" forever: coachmark tours and
761
+ showcases replayed on every open, and a review gate configured with `minSessions` 0/1 could
762
+ RE-FIRE its native prompt each open (the 2026-07-16 one-star incident class). A thenable probe on
763
+ first read (`validateGateStorage`, WeakSet-cached so each adapter pays for exactly one probe) now
764
+ substitutes a closed adapter: every gate reads "seen", nothing shows, nothing fires, and a
765
+ once-per-process dev warning names the fix (pass a sync adapter, e.g. a 3-line MMKV wrapper).
766
+ Never-fire is the only direction a once-per-user gate may err in. A rejecting probe promise is
767
+ detached, so a broken native store cannot surface as an unhandled rejection.
768
+ - Canary: `test/canary/asyncGateStorage.test.tsx` (6 tests, proven red with the probe neutered —
769
+ including the sync CONTROL that stays green) + the new
770
+ `@wireai/activation:asyncGateStorageWarned` latch registered in `globalSlotDiscipline`.
771
+
772
+ ### Docs
773
+
774
+ - **The "a `device_key` you passed is never overridden" line is now qualified everywhere it ships**,
775
+ on the ONBOARDING join-key path as it already was on the reviews path. The bar is the same on both
776
+ and it is `resolveIdentity` / `hasDeviceKey`: a string with at least one non-whitespace character.
777
+ `userContext` also accepts numbers and booleans, so a non-string or an all-whitespace `device_key`
778
+ was never a usable join key — it reads as absent and the auto key is merged over it, which the
779
+ unqualified sentence denied. Corrected in `AGENTS.md`, `README.md` (both the props table and the
780
+ join-key rules), and the shipped JSDoc in `src/types.ts` + `src/WireOnboarding.tsx`. No behaviour
781
+ change: the predicate is unchanged, only the sentence describing it.
782
+ - `README.md`'s tarball install line named `@wireai/activation-0.1.3.tgz`, a file `npm pack` has
783
+ never produced under any version (it emits `wireai-activation-<version>.tgz`, scope flattened), so
784
+ an agent following it hit `ENOENT`. Fixed there and in the two Distribution mentions.
785
+
6
786
  ## [0.14.3] - 2026-08-18
7
787
 
8
788
  **PATCH: no public surface gained, lost or renamed a member.** `SpotlightOverlay`, `GestureHint`