@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.
- package/AGENTS.md +96 -21
- package/CHANGELOG.md +780 -0
- package/INTEGRATION_PROMPT.md +61 -23
- package/README.md +110 -31
- package/dist/analytics/index.d.mts +35 -13
- package/dist/analytics/index.d.ts +35 -13
- package/dist/analytics/index.js +288 -127
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +288 -127
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +15 -1
- package/dist/coachmarks/index.d.ts +15 -1
- package/dist/coachmarks/index.js +120 -22
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +120 -22
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-CUvTOchb.d.mts → currentSession-Bz7G6lno.d.mts} +35 -39
- package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-z-CZ55ad.d.ts} +35 -39
- package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
- package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +125 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +125 -36
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +3 -16
- package/dist/questionnaire/index.d.ts +3 -16
- package/dist/questionnaire/index.js +202 -46
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +203 -47
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +10 -6
- package/dist/reviews/index.d.ts +10 -6
- package/dist/reviews/index.js +269 -52
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +270 -53
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +1 -1
- package/dist/showcase/index.d.ts +1 -1
- package/dist/showcase/index.js +106 -20
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +107 -21
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
- package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
- package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
- package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
- package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
- package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
- package/llms.txt +9 -9
- package/package.json +6 -9
- package/src/WireOnboarding.tsx +4 -1
- package/src/analytics/currentSession.ts +141 -4
- package/src/analytics/index.ts +6 -1
- package/src/analytics/reportClientEvent.ts +19 -10
- package/src/analytics/useAnalytics.ts +74 -15
- package/src/analytics/wireDoctor.ts +152 -7
- package/src/coachmarks/CoachmarkProvider.tsx +26 -5
- package/src/coachmarks/runtime.ts +156 -2
- package/src/coachmarks/types.ts +7 -2
- package/src/coachmarks/useCoachmarkTour.ts +51 -1
- package/src/context/deviceId.ts +72 -6
- package/src/features/WireFeaturesProvider.tsx +72 -12
- package/src/features/fetchWireFeatures.ts +49 -11
- package/src/features/useWireFeatures.ts +39 -3
- package/src/identity/identityRecord.ts +15 -2
- package/src/questionnaire/QuestionnaireGate.tsx +40 -1
- package/src/questionnaire/transport.ts +22 -8
- package/src/questionnaire/useQuestionnaireGate.ts +58 -7
- package/src/reviews/ReviewGate.tsx +125 -37
- package/src/reviews/decision.ts +11 -1
- package/src/reviews/idempotency.ts +109 -0
- package/src/reviews/index.ts +4 -2
- package/src/reviews/runtime.ts +44 -13
- package/src/reviews/transport.ts +39 -20
- package/src/reviews/types.ts +7 -0
- package/src/reviews/useReviewGate.ts +57 -7
- package/src/session-analytics/lifecycle.ts +16 -0
- package/src/session-analytics/useLifecycleEvents.ts +30 -2
- package/src/session-analytics/useSessionStart.ts +22 -2
- package/src/showcase/FeatureShowcase.tsx +50 -3
- package/src/types.ts +15 -8
- package/src/utils/submitResult.ts +17 -9
- 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`
|