@mindful-web/marko-web-identity-x 1.83.0 → 1.85.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 (35) hide show
  1. package/CLAUDE.md +301 -7
  2. package/api/mutations/progressive-profile-update.js +29 -0
  3. package/browser/index.js +13 -0
  4. package/browser/progressive-profile.vue +388 -0
  5. package/components/access.marko.js +2 -2
  6. package/components/comment-stream.marko.js +2 -2
  7. package/components/context.marko.js +2 -2
  8. package/components/form-access.marko.js +2 -2
  9. package/components/form-authenticate.marko.js +2 -2
  10. package/components/form-change-email.marko.js +2 -2
  11. package/components/form-login.marko.js +2 -2
  12. package/components/form-logout.marko.js +2 -2
  13. package/components/form-profile.marko.js +2 -2
  14. package/components/form-register.marko.js +2 -2
  15. package/components/google-init.marko.js +2 -2
  16. package/components/google-sign-in-button.marko.js +2 -2
  17. package/components/identify.marko.js +2 -2
  18. package/components/marko.json +16 -0
  19. package/components/non-auth-identify.marko +6 -1
  20. package/components/non-auth-identify.marko.js +3 -3
  21. package/components/progressive-profile-form.marko +106 -0
  22. package/components/progressive-profile-form.marko.js +138 -0
  23. package/components/subscribe.marko.js +2 -2
  24. package/config.js +98 -0
  25. package/package.json +6 -5
  26. package/routes/google.js +5 -0
  27. package/routes/index.js +3 -0
  28. package/routes/progressive-profile.js +296 -0
  29. package/service.js +31 -0
  30. package/utils/add-custom-attributes.js +19 -46
  31. package/utils/build-progressive-profile-payload.js +117 -0
  32. package/utils/get-missing-progressive-fields.js +114 -0
  33. package/utils/get-progressive-form-fields.js +67 -0
  34. package/utils/progressive-profile-cookies.js +120 -0
  35. package/utils/progressive-profile-telemetry.js +148 -0
package/CLAUDE.md CHANGED
@@ -99,16 +99,29 @@ path of authentication. To compare upstreams:
99
99
  **`MindfulApiCall` is sampled.** Unsampled it ran ~55k events/hour / ~40M per month on the Fusable
100
100
  fleet, 88.9% of it `GetActiveAppContext` + `CheckContentAccess` — per-page-load context calls that
101
101
  are uniformly fast and analytically useless. Kept: **every non-`ok` outcome**, **every `ok` call
102
- >= `MINDFUL_API_CALL_SLOW_MS`** (default 250ms — deliberately under Omeda's observed p95 of 282ms, so
103
- ordinary Omeda latency stays visible while fast idx calls fall away), and **1 in
104
- `MINDFUL_API_CALL_SAMPLE_RATE`** (default 100) of the rest. Each event carries `sampleRate` = the
105
- number of real calls it represents, so `sum(sampleRate)` recovers true volume; `sampleRate = 1` means
106
- the call was kept because it failed or was slow.
102
+ >= `MINDFUL_API_CALL_SLOW_MS`** (default 250ms) *whose operation is not in*
103
+ **`MINDFUL_API_CALL_NO_SLOW_CAPTURE`** (default `GetActiveAppContext,CheckContentAccess`), and **1 in
104
+ `MINDFUL_API_CALL_SAMPLE_RATE`** (default 100) of everything else. Each event carries `sampleRate` =
105
+ the number of real calls it represents, so `sum(sampleRate)` recovers true volume; `sampleRate = 1`
106
+ means the call was kept because it failed or was slow.
107
+
108
+ **Why the exclusion list exists.** The 250ms threshold alone did not hold. It was tuned against a
109
+ `GetActiveAppContext` p95 of 206ms measured over only **1.4 hours**; over 48h that operation cleared
110
+ 250ms constantly and produced **96.3% of all kept events** (259,448 of 269,322 — 5,625/hour), so the
111
+ sampler barely applied to the one operation generating the volume. Excluding the two per-page-load
112
+ context operations from *slow*-capture drops them through to the 1-in-N sampler instead. Raising the
113
+ threshold to ~1s was the alternative and is worse: it costs fidelity on every *other* operation to
114
+ fix two. Errors and timeouts are unaffected — `GetActiveAppContext` contributed 28 errors and the
115
+ only observed timeout (15,009ms against the 15s budget), and all of it still flows.
107
116
 
108
117
  Consequences when querying: `average(ms)` over everything **skews high** (fast calls are
109
118
  under-represented by design) — filter `WHERE sampleRate = 1` for the error/slow tail, or
110
- `WHERE sampleRate > 1` for a fast-call view. Set `MINDFUL_API_CALL_SAMPLE_RATE=1` to record
111
- everything again during an investigation, no deploy required.
119
+ `WHERE sampleRate > 1` for a fast-call view. For the excluded operations the skew runs the *other*
120
+ way: every success is sampled regardless of duration, so their `sampleRate = 1` rows are errors and
121
+ timeouts **only**, never a slow tail — use `sampleRate > 1` with `percentile(ms, 95)` for their
122
+ latency. Set `MINDFUL_API_CALL_SAMPLE_RATE=1` to record everything again during an investigation, or
123
+ `MINDFUL_API_CALL_NO_SLOW_CAPTURE=` (explicitly empty) to restore slow-capture for every operation —
124
+ neither needs a deploy. An *unset* `NO_SLOW_CAPTURE` means the default list, not "exclude nothing".
112
125
 
113
126
  It wraps `client.query`/`client.mutate`, **not** `fetch` — `@parameter1/omeda-graphql-client` sets
114
127
  `fetch` *after* spreading the caller's `linkConfig`, so a wrapped transport cannot be injected from
@@ -310,6 +323,287 @@ after `setIdentity`. `google-auth-library` is now a base-idx dependency (every s
310
323
  server-only) — accepted because the feature is gated on the client id. The Google OAuth app must
311
324
  list each site origin as an authorized JavaScript origin or the surfaces silently no-show.
312
325
 
326
+ ## Progressive Profiling — added 2026-08
327
+
328
+ Detects an **identified or authenticated** visitor with unanswered demographic attributes, gates
329
+ the article, and asks only the missing questions. The audience that matters is
330
+ *identified-but-not-authenticated* — the `__idx_idt` holder arriving from an email link, the
331
+ majority of who we know. Today those users' form answers go only to an anonymous content-access
332
+ submission and never touch their AppUser record (`form-access.marko:100-105` sets
333
+ `updateProfileOnSubmit = false`); this makes that write real.
334
+
335
+ **Dormant fleet-wide.** `progressiveProfileFieldRows` defaults to `[]`; nothing renders and no
336
+ shared behavior changes until a site declares fields *and* enables the theme's `progressiveProfile`
337
+ site config (Part 2/3, `marko-web-theme-monorail` + the website repos).
338
+
339
+ ### The one hard constraint: attributes only
340
+
341
+ The backing mutation is `progressiveProfileUpdate` on **idx-compat**
342
+ (`mindful-reporting#263`, shipped 2026-08-05). It writes member **attributes** — select, boolean,
343
+ text — and nothing else; built-in/core member fields were cut on review. A built-in column would
344
+ render, accept a value, and then be silently unwritable: the user submits, gets `changed: false`,
345
+ and is re-gated forever. `config.js` therefore **throws at boot** on one, and on three other
346
+ configurations that are each a user-facing trap:
347
+
348
+ | Boot check | Why the runtime failure is worse than a failed deploy |
349
+ | --- | --- |
350
+ | `type: 'built-in'` | Unwritable ⇒ permanent gate loop. **Silent** — the only one of these with no visible symptom until a user is stuck. |
351
+ | `type: 'custom-boolean'` | `custom-column.vue` binds the checkbox to `customField.answer` but its handler writes `customField.value`, so a *required* checkbox can never be checked. The diff util, payload builder and mutation all handle booleans correctly — only the shared renderer does not. Re-allow once that is fixed. |
352
+ | any other type | Falls through to `custom-column.vue`'s `<pre v-else>` debug block, printing raw field metadata to a real visitor. |
353
+ | id missing from a non-empty `activeCustomFieldIds` | `routes/profile.js:88-104` silently discards the answer ⇒ gate loop. |
354
+ | rows configured with no `apiToken` | Every write uses `getOrgUserApiToken()` and the form token is signed from it. |
355
+
356
+ No coverage is lost: every field the fleet-wide impact analysis measured is a select attribute.
357
+
358
+ ### Files
359
+
360
+ - `utils/get-missing-progressive-fields.js` — the diff. Matches on **`answer.field.id`**, never
361
+ `answer.id`. That distinction is the whole reason this feature does not reuse
362
+ `get-form-custom-fields.js` (or `config.getGTMUserData`): an idx answer's `id` is the *answer's*
363
+ id, so comparing it to a configured field id never matches, and the truthy-empty-array bug that
364
+ follows returns an answer object with no `field`. Returns `missingRows` with every column forced
365
+ `required: true`, so `custom-column.vue` needs no changes.
366
+ - `utils/get-progressive-form-fields.js` — correct, feature-scoped replacement for
367
+ `get-form-custom-fields.js`. Stamps `id` with the **field** id, which is the contract
368
+ `custom-column.vue` reads and what `get-create-user-custom-fields.js` already emits.
369
+ - `utils/build-progressive-profile-payload.js` — marshals the body into mutation variables, plus
370
+ `buildContentSource()`. **Definitions come from server config, never the body**, so a client
371
+ cannot widen what gets written by posting extra rows. Drops an empty select rather than sending
372
+ `optionIds: []`, which the writer reads as *unset* — the one way this feature could destroy data
373
+ despite the fill-blanks-only rule.
374
+ - `routes/progressive-profile.js` — `POST /__idx/progressive-profile` (+ `/dismiss`). A thin proxy;
375
+ it owns *trust*, not writes.
376
+ - `components/progressive-profile-form.marko` (`<marko-web-identity-x-progressive-profile-form>`)
377
+ + `browser/progressive-profile.vue` (`IdentityXProgressiveProfile`, overridable via
378
+ `CustomProgressiveProfileComponent`).
379
+ - `utils/progressive-profile-telemetry.js` — resolves the New Relic agent (injected first, lazy
380
+ fallback) and exposes `createRecorder`, which emits one typed event per submit. See *Telemetry*.
381
+ - `utils/progressive-profile-cookies.js` — the attempts escape valve, the optional dismissal
382
+ suppression, and the 3-day suppression that follows an unverified submit. **Not** bot safeguards;
383
+ that is reCAPTCHA's job, below.
384
+
385
+ ### What happens server-side of the route
386
+
387
+ Everything, in one Mongo transaction: the re-diff that drops already-filled fields, the attribute
388
+ writes, the `action-history` entries attributed via `context.source = 'PROGRESSIVE_PROFILE'`, and
389
+ one `member-events` document of kind `PROGRESSIVE_PROFILE_SUBMIT`. The route does no
390
+ fetch-then-write of its own, and **no session is ever minted** — a visitor stays merely identified,
391
+ so a bot cannot come away holding one. There is no `actionId` client-side; attribution lives in
392
+ `action-history`.
393
+
394
+ Auth picks the target and the client never does: an authenticated visitor writes as themselves
395
+ (`AppUser` token, `userId` ignored by the resolver), an identified one is written by the org token
396
+ with the **resolved** `targetId` — `body.userId` is never trusted and a mismatch is a 403.
397
+
398
+ Fires `onUserProfileUpdate` only when `changed`, guarded, reported through `onHookError`. That hook
399
+ is **provider-agnostic** — Omeda rapid-identify is the most visible consumer but one of five
400
+ (`marko-web-identity-x-mailchimp`, `marko-web-postup`, plus site-level Braze and ActiveCampaign
401
+ packages). The `profileUpdateHooks` field records `dispatched | skipped | error`, and `dispatched`
402
+ deliberately does **not** mean any hook succeeded: every registrant uses `shouldAwait: false`, and
403
+ `utils/call-hooks-for.js` wraps each promise in `.catch(onHookError)`, so `callHooksFor` returns
404
+ before the hooks run and essentially never rejects. An async failure is reported there and can never
405
+ reach this field; `error` is a *synchronous* dispatch throw only. Deliberately **not** `onLoginLinkSent` / `onAuthenticationSuccess`: those drive
406
+ auto-signup and the GA4 `subscribe` conversion, and this is not a registration.
407
+
408
+ ### Bot protection — verify, then discard the write rather than the visitor
409
+
410
+ The threat is concrete: `marko-web-omeda-identity-x/middleware/set-identity-cookie.js` mints
411
+ `__idx_idt` for a **real person's** AppUser id on any request carrying `oly_enc_id`, so a newsletter
412
+ security scanner following an emailed link browses *as* an identified human and could write to a
413
+ real record. Cloudflare on these sites segregates rather than drops and scores 30–99 reach the
414
+ origin, so there is no upstream to lean on.
415
+
416
+ Verification is the fleet standard — **reCAPTCHA v3 via `@mindful-web/marko-web-recaptcha`**, the
417
+ same package, 0.5 floor and client shape as newsletter signup, contact-us, inquiries and the RigDig
418
+ checkout. Action `progressiveProfileSubmit`, declared client-side and checked server-side so a token
419
+ minted for another form cannot be replayed here.
420
+
421
+ **What differs is the response to a failure: the write is dropped, never the visitor.** The
422
+ submission "succeeds", the content unlocks, and the answers go nowhere. Every other form on the
423
+ fleet returns a hard 400 the visitor can do nothing about, which is right when the worst case is a
424
+ retried newsletter signup. Here the form is the only way past a gate on the article, so rejecting a
425
+ false positive locks a real reader out of content they can otherwise reach — a bigger loss than the
426
+ bot write it prevented. A bot gains an article it could largely have read anonymously, and gains no
427
+ write, which is the only thing actually at stake.
428
+
429
+ An earlier revision escalated to a reCAPTCHA v2 checkbox instead. Removed: it cost a second key pair
430
+ maintained fleet-wide plus a chunk of client code, to solve a problem **nobody has evidence exists**
431
+ — it is unknown whether newsletter scanners submit forms at all, or only load pages. Measure first
432
+ (below); the code is recoverable from `0f35bb1` if the data ever justifies it.
433
+
434
+ **Every failure mode passively accepts** — `low-score`, `no-token`, `bad-token`, `stale-token`,
435
+ `wrong-action` — each recorded separately. `no-token` matters as much as the rest: it is what an ad
436
+ blocker eating `api.js` produces, and the client deliberately **does not disable submit** when the
437
+ script fails to load (as the other forms do) because that would strand a reader behind a gate with
438
+ nothing to click.
439
+
440
+ **One rule for every visitor.** A failed check drops the write whether or not a session is present.
441
+ An earlier revision exempted session holders — `/user/profile` has no captcha either, so a session
442
+ is already sufficient authorization for a profile write elsewhere in this package — but that is a
443
+ user-initiated edit on a page someone navigated to, while this is an interstitial pushed at a
444
+ link-follower, which is exactly what an email scanner hits. Scanners follow magic links, so a
445
+ session is not proof of a human. The exemption also bought nothing: the score is recorded either
446
+ way, so authenticated submits still serve as the known-human control group.
447
+
448
+ **The suppression cookie is mandatory, not a nicety.** A discarded submit leaves the member's record
449
+ unchanged, so the diff would gate them again on the very next article — forever. `__idx_pp_sup`
450
+ (3 days, `progressiveProfile.unverifiedSuppressDays`) stops that and hands the visitor back to the
451
+ content meter, exactly as dismissal does. It is deliberately a **different cookie** from
452
+ `__idx_pp_dis`: "user skipped" and "we threw their answers away" need to be distinguishable in
453
+ Part 2's debug output and in support.
454
+
455
+ **The honest cost, and why the telemetry ships with this and not after.** A false-positive human
456
+ fills the form in good faith, nothing is saved, and three days later they are asked again —
457
+ permanently, invisibly to them *and to us*. The v2 challenge let them rescue themselves; this does
458
+ not. So the score is recorded on **every** submit, passing or not:
459
+ **one custom event per submit, typed by what happened** — `ProgressiveProfileSubmit` (the mutation
460
+ ran), `ProgressiveProfileDiscarded` (passive accept), `ProgressiveProfileRejected` (401/403/400,
461
+ carries `reason`) or `ProgressiveProfileError` (threw, or fell through unrecorded). Shared
462
+ attributes: `contentId`, `ms`, then `recaptcha`/`recaptchaScore` from the verify step and
463
+ `userId`/`auth` once the identity resolves; `Submit` adds `fieldCount`, `changed`,
464
+ `profileUpdateHooks`. Never the submitted answers.
465
+
466
+ Split by type rather than an `outcome` field so each outcome is independently countable and
467
+ alertable — "how many passive submits" is a bare `count(*)`, not a filter. There is deliberately no
468
+ `outcome`/`accepted` attribute; the type carries it, and two encodings drift. (The HTTP response
469
+ still returns `accepted` — that is the dataLayer's contract, unrelated.)
470
+
471
+ **The arithmetic is enforced, not left to discipline.** Each terminal branch records its own type,
472
+ `createRecorder` makes `record` a no-op once anything has fired, and a `finally` backstop catches a
473
+ path that recorded nothing — as `ProgressiveProfileError` with `reason: 'unrecorded'`, so a branch
474
+ added without a call site surfaces in New Relic instead of quietly skewing the mix.
475
+
476
+ **Why a custom event and not transaction attributes — and specifically not because of sampling.**
477
+ Measured before switching: these apps run 165–588 transactions/minute against a ~10k reservoir,
478
+ `Transaction` data is queryable 35 days back, and `idxGoogleStep` attributes land and query fine
479
+ today. Every New Relic destination is reservoir-backed anyway (`transaction_events`,
480
+ `custom_insights_events`, `application_logging.forwarding`), so there is no unsampled path to move
481
+ to; `recordLogEvent` would have been worst of all here, since every Log record on this account
482
+ arrives via the HTTP Log API with no `entity.guid` — no APM agent forwards application logs, so it
483
+ would likely have no-opped. The real reasons: the decision rule needs `histogram(recaptchaScore)`
484
+ and a dedicated event type is the right shape for it, `FROM Transaction WHERE name LIKE '%…%'` is a
485
+ string match a route rename silently breaks, and the measurement stops depending on `Transaction`
486
+ retention staying where someone set it. **Do not "fix" this back to `addCustomAttributes` on the
487
+ grounds that sampling was never a problem — it wasn't.**
488
+
489
+ **Deliberately unsampled**, unlike `MindfulApiCall`, which needed a hand-written sampler at ~40M
490
+ events/month. This is one row per gate submission: `count(*)` is the true count and there is no
491
+ `sampleRate` attribute.
492
+
493
+ The agent comes from `req.app.locals.newrelic` when a site passed one to `startServer`
494
+ (mindful-web#314), falling back to the shared lazy resolver in `@mindful-web/utils` — the option is
495
+ new and **no site passes it yet**, so without the fallback this would go dark fleet-wide. That
496
+ fallback is also why `utils/add-custom-attributes.js` is now a one-line delegation to
497
+ `newrelicAgent.addCustomAttributes`: it used to carry its own line-for-line copy of the same agent
498
+ resolution. `routes/google.js` is unchanged and keeps its step attributes on the transaction, where
499
+ `idxGoogleStep` riding on `TransactionError` is what makes a 500 self-describing.
500
+
501
+ Capturing the score required a small addition to the shared package: `verifyToken()` returns
502
+ `{ success, score, action, hostname, errorCodes }` instead of throwing, and `validateToken` is now a
503
+ thin policy layer over it with an unchanged contract. Reach for `verifyToken` when a caller needs to
504
+ *decide*; `validateToken` remains right for a form that should just refuse. **Two traps if you touch
505
+ it:** `science-medicine-group-websites`'s preference center echoes `e.message` verbatim to the
506
+ browser, so those three strings are user-facing copy; and `fusable-websites/packages/payfabric`
507
+ catches on `error.code`, which `http-errors` does not set, so a rejection there surfaces as a 500 —
508
+ adding a `.code` would silently flip it to 400. There is also a live regression trap inside
509
+ `validateToken` itself, commented at the call site: the original compared `undefined < minimumScore`
510
+ against the raw body, so a success carrying no score *passed*; `verifyToken` normalizes that to
511
+ `null`, and `null < 0.5` is true.
512
+
513
+ **The decision rule.** Run these after launch (`~/.newrelic/nrql`, account 2723345) and act on them;
514
+ a rejection rate is unactionable without the distribution behind it.
515
+
516
+ ```sql
517
+ -- The mix. Rates span types with a comma-union, which NRQL supports natively.
518
+ SELECT count(*) FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded,
519
+ ProgressiveProfileRejected, ProgressiveProfileError FACET eventType() SINCE 1 day ago
520
+
521
+ -- Passive submits, and why.
522
+ SELECT count(*) FROM ProgressiveProfileDiscarded FACET recaptcha SINCE 1 day ago
523
+
524
+ -- The discard rate the rollout table keys on.
525
+ SELECT percentage(count(*), WHERE eventType() = 'ProgressiveProfileDiscarded')
526
+ FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded SINCE 1 week ago
527
+
528
+ SELECT histogram(recaptchaScore, 1.0, 10)
529
+ FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded SINCE 1 week ago
530
+
531
+ -- Authenticated submits are the known-human control group: if their p5 sits below the
532
+ -- threshold, the threshold is wrong, not the traffic.
533
+ SELECT percentile(recaptchaScore, 5, 25, 50)
534
+ FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded FACET auth SINCE 1 week ago
535
+ ```
536
+
537
+ | Discard rate | Score shape | Action |
538
+ | --- | --- | --- |
539
+ | < 2% | any | Done. Passive accept is permanent. |
540
+ | 2–10% | clustered below ~0.2 | Bots. Keep passive accept. |
541
+ | 2–10% | clustered 0.3–0.5 | Lower `recaptchaMinimumScore`, re-measure. Far cheaper than a challenge. |
542
+ | > 10% | any | Build the v2 escalation — `0f35bb1`. |
543
+
544
+ **What actually bounds the damage** is not on this route and never was: the mutation re-diffs inside
545
+ its own transaction and can only ever fill a field that was already empty, so nothing that gets past
546
+ reCAPTCHA can destroy data. Every write also lands in `action-history` with its `input`/`result`, so
547
+ a bad one is findable and reversible from one collection.
548
+
549
+ **Escape valve.** `progressive-profile-cookies.js` tracks gate renders; the theme middleware
550
+ increments and releases the content past `maxAttemptsPerUser`, and this route clears the counter on
551
+ any success. The known cause of an unclearable gate is gone structurally, but a gate that can trap a
552
+ real person is worse than no gate.
553
+
554
+ ### Telemetry & analytics
555
+
556
+ One custom event per submit, typed by outcome — `ProgressiveProfileSubmit` / `Discarded` /
557
+ `Rejected` / `Error`. Shared attributes `recaptcha`, `recaptchaScore`, `auth` (`org|user`; `FACET`
558
+ it to see how much traffic is identified-but-not-authenticated, the population the feature exists
559
+ for), `userId`, `contentId`, `ms`; `Submit` adds `changed`, `fieldCount`, `profileUpdateHooks`. See *Bot protection* above for why this is an event rather than transaction
560
+ attributes, and for the decision rule it feeds. Operational only — **submission counts come from
561
+ `member-events`.**
562
+
563
+ The one thing still on the transaction is `onHookError`'s payload
564
+ (`idxProgressiveProfileStep`, `idxUserId`), which rides on `noticeError` — a separate seam, and the
565
+ right place for it: a non-fatal hook failure belongs next to the error it describes.
566
+
567
+ The dataLayer bridge and the p1events bridge are independent subscribers to the same EventBus, so
568
+ what reaches each is decided separately:
569
+
570
+ - **p1events: form views only.** A dedicated branch in `marko-web-p1-events/browser/index.js`
571
+ (`Identity / View / Progressive Profile`, top-level content `entity`, `props` carrying
572
+ `audience`, `fieldCount`, `fieldKeys`). No submit event: `member-events` is written by the
573
+ mutation that did the work, inside its transaction, so no event means no write. Nothing is added
574
+ to `identifyOnSubmit` — the session is already identified before the form renders.
575
+
576
+ **Consequence of passive accept:** a discarded submit writes no `member-events` row, so the
577
+ abandonment funnel (view identities minus `PROGRESSIVE_PROFILE_SUBMIT`) counts it as abandonment.
578
+ Arguably correct — no data was collected — but "never submitted" and "submitted and we threw it
579
+ away" are different populations, and only the New Relic `unverified` count separates them.
580
+ - **GA4/dataLayer keeps a submit event**, carrying server-truth `changed` so the conversion trigger
581
+ can filter on `changed === true`. A discarded (unverified) submit reports `changed: false`, so it
582
+ is excluded from completions with **no GTM change**; the accompanying `accepted` value
583
+ (`written|unverified`) just makes discards separately countable. GA4 will read **lower** than
584
+ `member-events` (blockers, bot filters, unload races); the direction is always undercount.
585
+ Reconciling them is a watchlist item, not a bug.
586
+
587
+ **Trap:** `global-event-emitter.js` assigns `additionalEventData` *after* spreading `data`,
588
+ clobbering anything the caller passed under that key — which is why `access.vue`'s `ent:` never
589
+ reaches the bridge. The content entity is passed as `emit`'s third argument instead.
590
+
591
+ Submitted **answer values are never tracked** anywhere — `fieldKeys` and the member-event's
592
+ `fieldIds` record which questions were asked, nothing more.
593
+
594
+ ### Two live follow-ups, neither in this package
595
+
596
+ 1. 🔴 **Index drop/recreate, production.** `completed_auth_date_website_channel` and
597
+ `completed_auth_email_website_channel` had their `partialFilterExpression` widened; a changed
598
+ partial filter does not alter an index in place, so both must be dropped and recreated. Until
599
+ then progressive-profiling `member-events` queries collection-scan. Owner: audience/infra.
600
+ 2. **The shared-code PR.** `get-form-custom-fields.js`, the commented-out `custom-boolean` branches
601
+ in `generate-required-field-payload.js`, the `routes/profile.js` allowlist, `custom-column.vue`'s
602
+ boolean binding, and the p1events bridge's `contentGatingType`/`contentGateType` mismatch (every
603
+ producer in the fleet sets the latter, so gate type has **never** reached p1events for any
604
+ gate). All shared with live flows across 18 repos — deliberately not fixed alongside a new
605
+ feature.
606
+
313
607
  ## Fleet / rollout
314
608
 
315
609
  Not tracked in-repo. The Google-auth architecture change + fleet rollout plan lives outside the repo
@@ -0,0 +1,29 @@
1
+ const gql = require('graphql-tag');
2
+ const userFragment = require('../fragments/active-user');
3
+
4
+ /**
5
+ * The single consolidated write behind the progressive-profile gate.
6
+ *
7
+ * Everything happens server-side of this boundary, in one Mongo transaction: the re-diff that
8
+ * drops already-answered fields, the attribute writes, the `action-history` entries attributed to
9
+ * `PROGRESSIVE_PROFILE`, and the `PROGRESSIVE_PROFILE_SUBMIT` member-event that is the
10
+ * authoritative submission record. The route does no fetch-then-write of its own.
11
+ *
12
+ * Accepts either auth mode: an `AppUser` token writes to its own member and `userId` is ignored;
13
+ * an `OrgUserApiToken` writes to `input.userId`, which is the common case here — the gate's
14
+ * primary audience is identified-but-not-authenticated. **No session is ever minted**, so a
15
+ * visitor stays merely identified and a bot cannot come away holding one.
16
+ */
17
+ module.exports = gql`
18
+ mutation ProgressiveProfileUpdate($input: ProgressiveProfileUpdateMutationInput!) {
19
+ progressiveProfileUpdate(input: $input) {
20
+ ok
21
+ changed
22
+ appUser {
23
+ ...ActiveUserFragment
24
+ }
25
+ }
26
+ }
27
+
28
+ ${userFragment}
29
+ `;
package/browser/index.js CHANGED
@@ -9,6 +9,7 @@ const Download = () => import(/* webpackChunkName: "identity-x-download" */ './d
9
9
  const Logout = () => import(/* webpackChunkName: "identity-x-logout" */ './logout.vue');
10
10
  const Login = () => import(/* webpackChunkName: "identity-x-login" */ './login.vue');
11
11
  const Profile = () => import(/* webpackChunkName: "identity-x-profile" */ './profile.vue');
12
+ const ProgressiveProfile = () => import(/* webpackChunkName: "identity-x-progressive-profile" */ './progressive-profile.vue');
12
13
  const CommentStream = () => import(/* webpackChunkName: "identity-x-comment-stream" */ './comments/stream.vue');
13
14
 
14
15
  const $graphql = createGraphqlClient({ uri: '/__graphql' });
@@ -25,6 +26,7 @@ export default (Browser, {
25
26
  CustomLoginComponent,
26
27
  CustomLogoutComponent,
27
28
  CustomProfileComponent,
29
+ CustomProgressiveProfileComponent,
28
30
  } = {}) => {
29
31
  const AccessComponent = CustomAccessComponent || Access;
30
32
  const AuthenticateComponent = CustomAuthenticateComponent || Authenticate;
@@ -35,6 +37,7 @@ export default (Browser, {
35
37
  const LoginComponent = CustomLoginComponent || Login;
36
38
  const LogoutComponent = CustomLogoutComponent || Logout;
37
39
  const ProfileComponent = CustomProfileComponent || Profile;
40
+ const ProgressiveProfileComponent = CustomProgressiveProfileComponent || ProgressiveProfile;
38
41
 
39
42
  window.IdentityX = new IdentityX();
40
43
 
@@ -48,6 +51,7 @@ export default (Browser, {
48
51
  Browser.register('IdentityXLogin', LoginComponent, { provide: { EventBus } });
49
52
  Browser.register('IdentityXLogout', LogoutComponent, { provide: { EventBus } });
50
53
  Browser.register('IdentityXProfile', ProfileComponent, { provide: { EventBus } });
54
+ Browser.register('IdentityXProgressiveProfile', ProgressiveProfileComponent, { provide: { EventBus } });
51
55
 
52
56
  // Ensure the client-side IdX context is refreshed when the authentication event occurs
53
57
  EventBus.$on('identity-x-authenticated', () => window.IdentityX.refreshContext());
@@ -66,6 +70,7 @@ export default (Browser, {
66
70
  'identity-x-login-mounted',
67
71
  'identity-x-logout-mounted',
68
72
  'identity-x-profile-mounted',
73
+ 'identity-x-progressive-profile-mounted',
69
74
  // Actions/submissions
70
75
  'identity-x-access-submitted',
71
76
  'identity-x-authenticated',
@@ -79,6 +84,12 @@ export default (Browser, {
79
84
  'identity-x-login-link-sent',
80
85
  'identity-x-logout',
81
86
  'identity-x-profile-updated',
87
+ // GA4 keeps a submit conversion for progressive profiling even though p1events does not:
88
+ // campaign/source attribution is impossible from the server-side `member-events` record.
89
+ // The conversion trigger must filter on `changed === true` so a no-op submit is not counted
90
+ // as a profile completion. Expect GA4 to read LOWER than `member-events` -- blockers, bot
91
+ // filters and unload races drop dataLayer pushes but not server writes.
92
+ 'identity-x-progressive-profile-submitted',
82
93
  // Errors
83
94
  'identity-x-access-errored',
84
95
  'identity-x-authenticate-errored',
@@ -90,6 +101,8 @@ export default (Browser, {
90
101
  'identity-x-login-errored',
91
102
  'identity-x-logout-errored',
92
103
  'identity-x-profile-errored',
104
+ 'identity-x-progressive-profile-errored',
105
+ 'identity-x-progressive-profile-dismissed',
93
106
  ].forEach((event) => {
94
107
  EventBus.$on(event, (args) => {
95
108
  if (!window.IdentityX) return;