@revturbine/sdk 0.11.7 → 0.11.9

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/CHANGELOG.md CHANGED
@@ -47,6 +47,168 @@ also require a changelog entry.
47
47
 
48
48
  ---
49
49
 
50
+ ## 0.11.9
51
+
52
+ ### The SDK can record app-owned trial revisions and evidenced account creation (BL-0237, plan 276 TASK-13)
53
+
54
+ **What changed.** Three new `@public` methods on `RevTurbineCustomerSdk` and the
55
+ matching capability on the Python and Rust ports:
56
+
57
+ | Method | Does |
58
+ |---|---|
59
+ | `recordTrialRevision(episode, labels?)` | Classifies one trial episode's facts and emits one `trial_revision` if — and only if — the evidence supports a revision |
60
+ | `syncTrialRevisions(labels?)` | Does the same for every episode the registered `'trial'` domain provider states |
61
+ | `recordAccountCreated(payload)` | Emits `account_created` with plan 276 REQ-3 evidence; refuses a user-grain source |
62
+
63
+ **Trial execution and ownership stay with your app.** RevTurbine runs no trial,
64
+ enrols nobody, and decides nothing about when a trial ended. There is no
65
+ trial-enrollment service and no server-side evaluation. These methods *record*
66
+ the fact your app states, after running the same pure classifier every
67
+ RevTurbine port runs — so the fact that reaches the warehouse agrees with the
68
+ fact your app believes.
69
+
70
+ **The classifier reads no clock.** An episode whose scheduled end has merely
71
+ passed emits **nothing** and comes back `pending_unknown`. An elapsed deadline
72
+ proves only that the time passed, never that your app ended anything — supply
73
+ `actual_end_at` plus an `end_evidence` when it did. A conversion links only its
74
+ own episode's commitment, at or after that evidenced end, so card collection at
75
+ enrolment and unrelated paid activity cannot convert a trial. A usage-metered
76
+ episode needs exhaustion evidence before it can expire.
77
+
78
+ **The gentle nudge.** Registering no `'trial'` provider is a perfectly valid
79
+ integration: nothing breaks, no decision changes, every trial keeps working. But
80
+ trial revisions then go unrecorded, so the SDK logs **one** info-level console
81
+ line per runtime the first time trials exist and nothing will record them:
82
+
83
+ > `[RevTurbine] No trial provider supplied; trial revisions will not be recorded — see https://docs.revturbine.com/sdk/trials. Register a domain provider with domain: 'trial', or call recordTrialRevision(episode) yourself. Trials keep working; only the lifecycle facts are missing.`
84
+
85
+ It fires from `setTrialInstances()` (the moment your app hands the SDK trials)
86
+ and from `syncTrialRevisions()`. It is never a warning and never an error.
87
+
88
+ **Local mode.** In `local_only` the SDK makes no server calls at all, so a
89
+ recorded revision reaches any registered event consumer and never RevTurbine.
90
+ The result says `recorded_locally` rather than `recorded`, so an integrator can
91
+ tell the difference instead of assuming the warehouse has it.
92
+
93
+ **The ports.** `revturbine.core.trials.record_trial_revision` (Python) and
94
+ `revturbine::trial_revision::record_trial_revision` (Rust) classify an episode
95
+ and return the **validated payload for the host to ship** through its existing
96
+ ingest path. They emit nothing themselves: the server ports have never held an
97
+ event transport, and adding one would make each a second, unversioned ingest
98
+ client. What they guarantee is that the payload is byte-identical to the browser
99
+ SDK's for the same facts, asserted by the
100
+ `trial_revision_classification` cross-language parity fixture.
101
+
102
+ | field | value |
103
+ |---|---|
104
+ | Lands in | `0.11.9` |
105
+ | Fails closed in | n/a — additive. No existing call site changes behaviour. |
106
+ | Action needed | None to keep working. To record trial lifecycle facts, register a `'trial'` domain provider or call `recordTrialRevision` where your app changes a trial. |
107
+ | Proven by | `web-sdk/trial-revision.test.ts`, `tests/parity/fixtures/trial_revision_classification.json` (ts/py/rs), scaffold `src/trials/models/trial-revision.test.ts` |
108
+
109
+ Requires `@revt-eng/*` **0.1.352**, which adds the `trial_revision` taxonomy
110
+ name, the `TrialRevisionPayload` / `AccountCreatedPayload` contracts and the
111
+ `classifyTrialRevision` classifier.
112
+
113
+ ---
114
+
115
+ ## 0.11.8
116
+
117
+ ### `account_id` falls back to a PREFIXED user key instead of a bare user id (BL-0117)
118
+
119
+ **What changed.** Every clickstream event the SDK sends to `/api/track` used to
120
+ carry `account_id: userContext.account_id || userId`. `TrackEvent.account_id`
121
+ was a required, min-length-1 field, so an app that identified no account had to
122
+ send *something* — and what it sent was the **user** id, bare and
123
+ indistinguishable from a real account. `monetization_funnel` and `cohort_rollup`
124
+ build their account map out of `events_clickstream.account_id`, and experiment
125
+ analysis reads that column whenever `analysis_unit = 'account'`: every such row
126
+ contributed a bogus `user_id → user_id` account, so an account-grain readout
127
+ returned a user-grain n while looking perfectly valid. The treatment-interaction
128
+ lane had the same hole from the other side — the SDK sent no `account_id` at all
129
+ and the ingest route's own `account_id ?? user_id` fallback stamped a user id
130
+ into `placement_presentations.account_id`.
131
+
132
+ Kent ruled on it, 2026-09-25 (**D-13**): *"Keep the user-id fallback but
133
+ explicitly prefix it to make it obvious its a fallback key."*
134
+
135
+ So the fallback stays — a row with no identified account still needs an
136
+ attribution handle, and a hole in a join key is not an improvement — but it is
137
+ now **self-describing**:
138
+
139
+ ```text
140
+ identified account → account_id: "acct_acme"
141
+ no account → account_id: "user-fallback:user_123"
142
+ ```
143
+
144
+ Both wire lanes (`/api/track` → `events_clickstream.account_id`, and
145
+ `/api/events/interactions` → `placement_presentations.account_id`) resolve the
146
+ value through one private helper, so they cannot drift apart: the funnel joins
147
+ one against the other, and a divergence in trimming, hashing or fallback
148
+ derivation joins nothing. The fallback is derived from the same
149
+ `userContext.id || anonymousId` the row's `user_id` comes from and redacted the
150
+ same way, so an email-shaped user id is the SAME hash behind the marker on both
151
+ lanes. A blank or whitespace-only `account_id` is not an account and falls
152
+ through to the fallback.
153
+
154
+ Two new `@public` exports carry the contract to integrators and to anything
155
+ reading the warehouse:
156
+
157
+ ```ts
158
+ import { FALLBACK_ACCOUNT_ID_PREFIX, isFallbackAccountId } from '@revturbine/sdk';
159
+
160
+ FALLBACK_ACCOUNT_ID_PREFIX; // 'user-fallback:'
161
+ isFallbackAccountId('user-fallback:user_123'); // true — fabricated
162
+ isFallbackAccountId('acct_acme'); // false — a real account
163
+ ```
164
+
165
+ `isFallbackAccountId` is position- and case-sensitive by contract, matching the
166
+ read-time SQL guards: an account id that merely *contains* the marker is a real
167
+ account, and the bare prefix with nothing after it is not a key at all. The same
168
+ constant and predicate ship in `server-python`
169
+ (`revturbine.core.account_identity`) and `server-rust`
170
+ (`revturbine::account_identity`, re-exported at the crate root), and the
171
+ cross-language parity corpus drives all three
172
+ (`tests/parity/fixtures/account_id_fallback_prefix.json`) so the literal cannot
173
+ drift between ports.
174
+
175
+ **Absence is still valid on the wire.** `@revt-eng/schema` 0.1.325 (scaffold
176
+ [#375](https://github.com/revt-eng/revturbine-scaffold/pull/375)) made
177
+ `TrackEvent.account_id` optional, and web migration 014 made
178
+ `events_clickstream.account_id` nullable — absence means "no account
179
+ identified", and a producer with no user identity to derive from may still omit
180
+ the field. The browser SDK always has an identity (an un-identified visitor
181
+ still has the anonymous id), so its default emit always sends the prefixed
182
+ fallback rather than a hole.
183
+
184
+ No public API change to an existing signature: nothing moved, and nothing new is
185
+ asked of the caller. `identify(userId, { account_id })` is still the only way to
186
+ supply an account. Integrations that identify one see no wire change at all.
187
+
188
+ **Read-time consequence, stated plainly.** Account-grain analytics exclude
189
+ fallback-prefixed ids from account denominators, so an app that never identifies
190
+ an account now reports **zero** accounts rather than one-per-user. That is a
191
+ correction, not a regression — those rows were never accounts — but a dashboard
192
+ that silently read them as account-grain will show a drop.
193
+
194
+ **Landed in** `0.11.8`. **Fail-closed in** `0.11.8` — the bare fallback is gone
195
+ in the same version that introduced the prefixed one; there is no window where
196
+ both shapes are emitted.
197
+
198
+ **Proving test:** `web-sdk/account-identity.test.ts` (the literal, the
199
+ classifier's position/case sensitivity, and the bare-prefix and absence cases)
200
+ plus `web-sdk/interaction-wire-contract.test.ts` — "the account identity on the
201
+ /api/track wire" and "the account identity the analytics joins key on": an
202
+ identified user with no account produces `user-fallback:<user_id>` on both
203
+ lanes, the prefix survives `/api/track` JSON serialization byte-for-byte, an
204
+ identified account reaches the wire byte-identically to the value supplied, a
205
+ blank account id is treated as absence, an un-identified visitor derives from
206
+ the anonymous id, and every body parses against the canonical
207
+ `TrackEventSchema` / `TreatmentInteractionInputSchema`. Also
208
+ `web-sdk/customer-side-ingest.test.ts` on the plain `capture()` path,
209
+ `server-python/tests/test_account_identity.py`, the unit tests in
210
+ `server-rust/src/account_identity.rs`, and the three-way parity fixture.
211
+
50
212
  ## 0.11.7
51
213
 
52
214
  ### A rebuilt gate/slot re-runs its check, so a page inside `<Gate>` no longer renders blank (BL-0251)
@@ -275,7 +275,7 @@ type TraitsNamespace = `traits:${string}`;
275
275
  * The `traits` domain and any `traits:<namespace>` domain are merged
276
276
  * into a single `traits` slot in `ResolvedProviderContext`.
277
277
  */
278
- type DomainProviderName = 'plan' | 'entitlements' | 'segments' | 'experiments' | 'content' | 'rules' | 'traits' | 'theme' | 'events' | 'cta' | TraitsNamespace;
278
+ type DomainProviderName = 'plan' | 'entitlements' | 'segments' | 'experiments' | 'content' | 'rules' | 'traits' | 'theme' | 'events' | 'cta' | 'trial' | TraitsNamespace;
279
279
  /** Immutable inputs available while resolving a domain provider. */
280
280
  interface DomainProviderResolutionInput {
281
281
  /** Current caller-supplied and trusted server-enriched context snapshot. */