# marko-web-identity-x — Claude Code Context

The Marko/Express wrapper for **IdentityX** (Parameter1's identity + access platform) on Mindful
Web sites. Owns the whole authenticated-user surface: login / register / authenticate (email-link)
/ profile / logout, content access + download gating, comments, and the server-side IdentityX API
client. Add-ons layer on top: `marko-web-omeda-identity-x` (wraps this and adds Omeda hooks —
how most sites install it), `marko-web-auth0-identity-x`, `marko-web-identity-x-mailchimp`. Google
Sign-In (One-Tap + button) is **built in** here (see below).

## Config model — the `IdentityXConfiguration` instance (`config.js`)

Unlike most Mindful packages, config is **not** read from `site.getAsObject(...)` — it's an
instance of `IdentityXConfiguration` (`new IdentityX({...})`), passed to the installer and injected
onto every request as `req.identityX.config`. Read in templates via
`req.identityX.config.get(...)` / `.getAsObject(...)` / typed getters.

```js
const IdentityX = require('@mindful-web/marko-web-identity-x/config');
const config = new IdentityX({
  appId: '<APP-ID>',                 // required
  apiToken: process.env.IDENTITYX_API_TOKEN,   // required for write ops
  requiredServerFields: ['organization', 'countryCode'],
  requiredClientFields: [...],       // gate access until present
  hiddenFields: [...], defaultCountryCode, booleanQuestionsLabel, gtmUserFields,
  onHookError: newrelic.noticeError.bind(newrelic),   // hook + google-auth error reporter
  googleAuth: { clientId, missingFieldsBehavior, autoPrompt, signInButtonEnabled }, // opt-in; see below
});
```

Arbitrary extra keys pass through `...rest` into `options` and are reachable via `config.get(path)`.
`endpointTypes` (`authenticate|changeEmail|login|logout|register|profile`) map to `/user/<type>`
routes via `getEndpointFor(type)` (override with an `endpoints` config key).

## Wiring (`index.js`)

`IdentityX(app, config, { templates })`:
1. `app.use(middleware(config))` — injects the service (below).
2. `app.use('/__idx', routes)` — the JSON API router.
3. For each `endpointType` with a supplied `template`, registers `GET <endpoint>` to render it
   (the `authenticate` endpoint redirects to profile when a token is already present).

Most sites don't call this directly — they call `omedaIdentityX(app, { idxConfig, idxRouteTemplates, … })`,
which adds Omeda integration hooks to `idxConfig` and then calls `identityX(app, idxConfig, …)`
internally. So **`googleAuth` goes on the `idxConfig` instance**, not the omeda params and not site config.

## Middleware (`middleware.js`)

Injects a per-request `IdentityX` **service** instance as `req.identityX` (+ `res.locals.identityX`),
reads the token cookie, and sets the identity cookie for logged-in users. `IdentityXRequest.identityX`
is the type every route/template relies on.

## Routes — `/__idx/*` (`routes/index.js`)

JSON API behind the Vue components: `authenticate`, `login`, `login-fields`, `logout`, `profile`,
`access`, `download`, `change-email/{initiate,confirm}`, `comment(s)`/`comment-count`/`flag`,
`countries`/`regions`, and **`google`** (see below). `logout.js` removes the context cookie, calls
`onLogout` (tokenless) or `logoutAppUser`, and **clears the GIS `g_state` cookie** unconditionally.

## Components / tags (`components/marko.json`)

Marko wrappers (`<marko-web-identity-x-*>`) that render a matching **browser Vue component**
(`browser/*.vue`, registered in `browser/index.js` as `IdentityX*`, overridable via
`Custom*Component`): `form-login`, `form-authenticate`, `form-register`, `form-profile`,
`form-change-email`, `form-logout`, `form-access`, `access`, `comment-stream`, `identify` /
`non-auth-identify`, `context` (a `|{ user, application, fields, isEnabled }|` provider), and the
Google tags. Vue components emit an EventBus stream bridged to `dataLayer` + p1events (see
`browser/index.js`).

## Service (`service.js`)

Server-side IdentityX GraphQL client (Apollo, idx-compat). Key methods: `loadActiveContext`,
`loadAppUserByEmail` / `findUserById` / `findUserByExternalId`, `createAppUser`,
`impersonateAppUser`, `logoutAppUser`, `addExternalUserId`, `setUnverifiedAppUserData`,
`generateEntityId`, `sendLoginLink`, `checkContentAccess`, cookie helpers (`setToken`,
`setIdentityCookie`, `getIdentity`). Org-scoped writes use `getOrgUserApiToken()`.

**Request timeout (`utils/create-client.js`).** Every idx call is bounded at **15s** per request
(`IDENTITYX_REQUEST_TIMEOUT_MS` to override; `linkConfig.fetchOptions.timeout` per client). This is
not a tuning knob, it is a correctness fix — **do not remove it.** `node-fetch` v2 has no default
timeout, so a stalled idx call previously hung the Express request forever: nothing threw, no
response was written, no 500 was returned, and because the New Relic Node agent records
`res.statusCode` — which defaults to **200** when nothing is ever written — the failure was logged as
a *successful* request with no error event. It was undetectable by design.

Found 2026-07 on `POST /__idx/google` (overdriveonline.com): 15 of 15 sign-ins that had just created
the app user stalled inside the impersonation mutation at avg **53.1s**, max **60.018s** — the flat
60s ceiling of an upstream idle timeout cutting a connection this process was still waiting on. Users
saw a One-Tap prompt that did nothing and had to trigger it a second time; 13 of 16 never came back.
Successful requests on the same route averaged 12.4s *total* across ~10 sequential idx calls, so a
15s per-call budget has ample headroom. Every operation is also wrapped by
`@mindful-web/utils` `instrumentApolloClient`, emitting a **`MindfulApiCall`** event
(`api`, `operation`, `kind`, `ms`, `outcome` of `ok|http-error|timeout|error`) plus a named trace
segment — a trace is the only place a call still *in flight* when its transaction dies leaves any
record. Same treatment on the Omeda client in `marko-web-omeda/middleware/graphql-client.js`
(20s, `OMEDA_REQUEST_TIMEOUT_MS`), which had the identical no-timeout defect and sits on the blocking
path of authentication. To compare upstreams:
`SELECT sum(sampleRate) FROM MindfulApiCall FACET api, operation`.

**`MindfulApiCall` is sampled.** Unsampled it ran ~55k events/hour / ~40M per month on the Fusable
fleet, 88.9% of it `GetActiveAppContext` + `CheckContentAccess` — per-page-load context calls that
are uniformly fast and analytically useless. Kept: **every non-`ok` outcome**, **every `ok` call
>= `MINDFUL_API_CALL_SLOW_MS`** (default 250ms) *whose operation is not in*
**`MINDFUL_API_CALL_NO_SLOW_CAPTURE`** (default `GetActiveAppContext,CheckContentAccess`), and **1 in
`MINDFUL_API_CALL_SAMPLE_RATE`** (default 100) of everything else. Each event carries `sampleRate` =
the number of real calls it represents, so `sum(sampleRate)` recovers true volume; `sampleRate = 1`
means the call was kept because it failed or was slow.

**Why the exclusion list exists.** The 250ms threshold alone did not hold. It was tuned against a
`GetActiveAppContext` p95 of 206ms measured over only **1.4 hours**; over 48h that operation cleared
250ms constantly and produced **96.3% of all kept events** (259,448 of 269,322 — 5,625/hour), so the
sampler barely applied to the one operation generating the volume. Excluding the two per-page-load
context operations from *slow*-capture drops them through to the 1-in-N sampler instead. Raising the
threshold to ~1s was the alternative and is worse: it costs fidelity on every *other* operation to
fix two. Errors and timeouts are unaffected — `GetActiveAppContext` contributed 28 errors and the
only observed timeout (15,009ms against the 15s budget), and all of it still flows.

Consequences when querying: `average(ms)` over everything **skews high** (fast calls are
under-represented by design) — filter `WHERE sampleRate = 1` for the error/slow tail, or
`WHERE sampleRate > 1` for a fast-call view. For the excluded operations the skew runs the *other*
way: every success is sampled regardless of duration, so their `sampleRate = 1` rows are errors and
timeouts **only**, never a slow tail — use `sampleRate > 1` with `percentile(ms, 95)` for their
latency. Set `MINDFUL_API_CALL_SAMPLE_RATE=1` to record everything again during an investigation, or
`MINDFUL_API_CALL_NO_SLOW_CAPTURE=` (explicitly empty) to restore slow-capture for every operation —
neither needs a deploy. An *unset* `NO_SLOW_CAPTURE` means the default list, not "exclude nothing".

It wraps `client.query`/`client.mutate`, **not** `fetch` — `@parameter1/omeda-graphql-client` sets
`fetch` *after* spreading the caller's `linkConfig`, so a wrapped transport cannot be injected from
outside that package (`fetchOptions` does survive, which is why the timeout can be). An
`ApolloLink(...).map()` was the other option and is worse: it only sees successful responses, so the
slow-and-failing calls would be exactly the ones missing.

## Hooks (`hooks.js`)

`onLoadActiveContext`, `onLoginLinkSent`, `onAuthenticationSuccess`, `onLogout`,
`onChangeEmailLinkSent`, `onChangeEmailSuccess`, `onUserProfileUpdate`. **Every route that fires a
hook passes a source**, and each hook names it differently: `onLoginLinkSent` gets `source`,
`onAuthenticationSuccess` gets `loginSource`, `onUserProfileUpdate` gets `actionSource`
(`profileUpdate` from `routes/profile.js`, `progressiveProfile` from
`routes/progressive-profile.js`; a client-supplied `additionalEventData.actionSource` wins over
either). `routes/profile.js` passed none until mindful-web#341, which is why that hook's Omeda
promo code was the tenant default on every save and its `actionSource` behavior attribute never
resolved. Adding it was safe because `build-behavior.js` **omits** an attribute whose
`valueIds.<source>` is missing rather than falling back to `valueIds.default` — no tenant maps
`profileUpdate`, so the emitted Omeda payload is byte-identical until one chooses to
(`marko-web-omeda-identity-x/test/build-behavior.spec.js` pins this). Registered via
`config.addHook({ name, fn, shouldAwait })` and invoked with `utils/call-hooks-for`. This is the
seam Omeda uses for rapid-identify + subscription sync. **Note:** `onLogout` currently fires only
in the tokenless logout branch — a "fire `onLogout` always" change would let the `g_state` clear
become a hook rather than inline route code.

## Boolean questions have three states — added 2026-08

`AppUserCustomBooleanFieldAnswer.answer` is a **nullable** `Boolean`, and the schema says so:
*"A null value signifies a non answer. It's up to the implementing components to account for this."*
`hasAnswered` is literally `answer != null`. The three states are `true`, `false`, and *never
answered* — and on a subscription question the third is not a "no", it is silence.

The write side has no null: `UpdateAppUserCustomBooleanAnswer { fieldId: String!, value: Boolean! }`
on both identity-x and idx-compat. **Omission is the encoding for "no statement"**, and it is
load-bearing at every layer below this package: an absent entry in the `answers` array is a no-op,
`marko-web-omeda-identity-x/rapid-identify.js` skips `hasAnswered: false` answers *and* anything
outside `restrictAnswersToFieldIds`, and the Omeda resolver does `if (optedIn == null) return;`.

**The profile form used to break this in two places, and no longer does.**

- `browser/form/fields/custom-boolean-yes-no.vue` — the profile form's subscription control. Two
  radios, and a `null` value selects **neither**, so unanswered is visually distinct from answered-
  no. Emits the boolean, never null: a user can move between yes and no but cannot un-answer.
  `required` is set on both radios and the browser validates them as a group — strictly better than
  the checkbox it replaced, which could only ever be satisfied by opting *in*.
  `browser/profile.vue`'s `onCustomBooleanChange` **assigns** the emitted answer; it used to
  `!answer`, which on a `null` would read a "no" click as `true`.
- `utils/build-boolean-answers-input.js` — `routes/profile.js` used to map every rendered answer
  with `value: Boolean(fieldAnswer.answer)` and push every field id into `submittedFieldIds`. A
  member who opened /user/profile to fix a typo and never touched the subscription block therefore
  sent `optedIn: false` / `Receive: 0` for every question they had never answered. The util drops
  `answer == null` before both the mutation and `submittedFieldIds`, so the Omeda hook's
  `restrictAnswersToFieldIds` drops them too. Spec:
  `test/utils/build-boolean-answers-input.spec.js`.

**This is fleet-wide and unconditional** — no config flag; it arrives on a `mindful-web` dep bump.

**Scope is `/user/profile` only.** `custom-boolean.vue` (the checkbox) is untouched and still
renders in `custom-column.vue` (register/login required rows), `access.vue`/`download.vue` (gating),
and the theme's `idx-newsletter-form/*`. Those forms only ever create opt-ins on user creation, and
a signup box reads better as a checkbox.

**What this cannot fix.** For channel-backed booleans (`_kind: EMAIL|MAGAZINE`), idx-compat derives
`answer` from the member's stored `subscriptions[].active`
(`idx-compat/src/resolvers/app-user.ts:76-101`) — so once *any* subscription doc exists,
`active: false` is indistinguishable from an explicit no and renders with **No** pre-selected.
Channels have no unanswered state. This prevents new damage; it cannot un-answer history.

## Google Sign-In (One-Tap + button) — folded in 2026-07

Verifies a Google Identity Services (GIS) credential server-side and drops the user into the
standard IdentityX session, bypassing the email-link step. Handles the One-Tap prompt **and** the
rendered "Sign in with Google" button; both post to `POST /__idx/google`. **Dormant until
`googleAuth.clientId` is set** — no client id ⇒ nothing renders and the route 400s. Previously a
standalone `marko-web-google-identity-x` package; folded in here because it was ~all ceremony gated
on a client id and already depended on idx internals.

**Config** (on the IdentityX config, above): `clientId` (master gate — per-deploy env),
`missingFieldsBehavior` (`profile-gate` default | `immediate` | `relaxed`; `relaxed` is a doc-only
alias of `immediate` — only `profile-gate` is special-cased), `autoPrompt` (site-wide floating
overlay), `signInButtonEnabled` (button; default true).

**Route — `routes/google.js` (`POST /__idx/google`).** Verify the JWT →
`loadAppUserByEmail`/`createAppUser` (**email is the primary key**) → link the Google `sub` as a
supplementary external id under `{ provider:'google', tenant:'oauth', type:'account' }` (idempotent;
conflict-guarded; non-fatal via `config.get('onHookError')`) → `impersonateAppUser({ method:'GOOGLE_ONE_TAP', verify:true })`
→ backfill given/family name only if absent → fire `onLoginLinkSent` **then** (re-fetch)
`onAuthenticationSuccess`, same order/hooks as email-link so Omeda rapid-identify + subscription
sync run identically. `profile-gate` computes `requiresUserInput` by checking required fields are
non-empty (deliberately not `forceProfileReVerification`). The impersonation mutation is **retried
once** on transport failures only (`idxImpersonateAttempts` records how many it took): stalls
correlated exactly with impersonating a user created milliseconds earlier — a read-after-write race on
the idx API — and every user who retried by hand succeeded on the second go. GraphQL validation and
authorization errors are deliberately *not* retried; they fail identically twice and only double the
user's wait.

**`onLoginLinkSent` is force-awaited here** (`callHooksFor(..., { forceAwait: true })`), because
everything after it depends on its side effects and `shouldAwait.onLoginLinkSent` defaults to
**false**. That default is right for email-link and wrong for One Tap, and the difference is
structural: email-link fires `onLoginLinkSent` on the `/login` request when the link is *sent* and
`onAuthenticationSuccess` on a separate request minutes later, so Omeda rapid-identify has long
finished. One Tap collapses both into one request milliseconds apart — un-awaited, rapid-identify has
usually not yet written the `encryptedCustomerId` external id when `appendSubscriptions` looks for it,
so it bails, writes no `autoSignups`, and **no `subscribe` conversion is ever reported**. That is the
`idxAutoSignupCount: 0` seen on every completed One Tap sign-in on overdriveonline.com. Safe to await
only because the Omeda client now has a request timeout.

**The name backfill is non-fatal, and must stay that way.** `GoogleSignInUpdateUser`
(`updateOwnAppUser`) is the **only** call in this route authenticated as the *user* rather than the
org — every other one passes `context: { apiToken: getOrgUserApiToken() }` — so it is the only one
depending on the session token minted moments earlier already being readable. Production shows it
returning *"The provided value is not a valid token"*, while the same token value works on the very
next request arriving from the cookie: a read-after-write race on the idx token store. Retried once
for that reason.

Non-fatal matters more than the retry. It backfills a first and last name — cosmetic — but
`tokenCookie.setTo` has **already run**, so the user is authenticated by the time it executes.
Throwing turned a fully successful sign-in into a 500, and the client, seeing `!data.ok`, ran `fail()`
and redirected a *logged-in* user to `/user/login`. A missing display name must never cost someone
their session. Failures report through `onHookError` and the route continues.

`FACET idxNameBackfill` (`ok` | `retried` | `failed`) distinguishes the two bugs that share this one
error message: if `retried` dominates it is the race; if `failed` dominates the impersonation token
simply cannot authorize this mutation, and the fix is to stop using it — backfill before impersonation
via `setUnverifiedAppUserData` (org-token, but writes *unverified* data, so it is a semantic change
worth deciding deliberately).

**Timing comes in two parts, deliberately.** `idxGoogleStepMs` is elapsed-at-last-step and
**last-write-wins** — on a completed request it is effectively total route time, and on one that dies
mid-flight it says how far in it got. It cannot attribute time to a step: every `setStep` overwrites
it, which is why the per-step breakdown it was originally meant to provide never actually survived
(confirmed in production — the query returned one value per transaction, not a profile).

`idxMs<Step>` is the real breakdown: one attribute per step (`idxMsVerifyCredential`,
`idxMsImpersonateAppUser`, `idxMsHooksLoginLinkSent`, `idxMsHooksAuthenticationSuccess`, …), each
holding how long **that step** took, emitted when the next step begins. `complete` has no duration of
its own — it is a marker, not work. The durations sum to `idxGoogleStepMs`. About ten attributes, well
inside New Relic's per-transaction budget.

```
SELECT average(idxMsHooksAuthenticationSuccess), average(idxMsImpersonateAppUser),
       average(idxMsHooksLoginLinkSent), average(idxGoogleStepMs)
FROM Transaction WHERE name LIKE '%__idx/google%'
```

Both hook steps sit on the blocking path and Omeda shows a ~4s tail, so this is what says whether
they own the remaining latency. Responds `{ ok, requiresUserInput,
redirectTo, user, entity, additionalEventData, … }`; the client redirects to `/user/profile` when
input is required.

**`additionalEventData` and the auto-signup union.** Tenant Omeda formatters *assign*
`additionalEventData.autoSignups` in place, and that object is the only channel by which the browser
learns a subscription was created (see analytics below). Where they assign it varies, and three
repos do it twice:

| Writes `autoSignups` in | Repos |
| --- | --- |
| `onLoginLinkSentFormatter` only | ab-media, allured, encore360, pmmi |
| `onAuthenticationSuccessFormatter` only | fusable, cox-matthews |
| **Both** | industrial (both pkgs), ironmarkets, watt |

So the route gives **each hook its own object** and merges the two afterwards, taking the union of
`autoSignups` deduped by `productId`. Sharing one reference (the obvious move, and what
`routes/authenticate.js` does — it only ever calls one hook) would let the second hook's assignment
clobber the first, silently dropping a whole set of subscribe events on the both-hook repos. In the
email-link flow those hooks fire on **separate requests** (login vs authenticate) and each reports
its own set, so the union is what preserves existing behaviour. Do **not** "simplify" this back to a
shared object.

`requiresUserInput` goes to `onAuthenticationSuccess` **only**: the onLoginLinkSent formatters read a
truthy value as "skip the Omeda subscription lookup" and then opt the user into every configured
deployment type without deduping, firing a phantom auto-signup for an existing Omeda customer who
merely lacks an idx required field. The email-link flow never exposes it there either —
`routes/login.js` 400s before any hook runs.

`idxAutoSignupCount` rides on the transaction attributes so "sign-in worked, Omeda updated, GA4
recorded nothing" — the exact shape of the July 2026 bug — is a visible number rather than something
a stakeholder finds in a report weeks later.

**Analytics parity (`components/google-init.marko`).** The Vue flows get `dataLayer` + p1events for
free from the EventBus bridge in `browser/index.js`; the One-Tap callback is plain inline JS on a
`<script>` tag, so it has to emit the same events by hand. Three things it must keep in step with
`browser/authenticate.vue` + `browser/mixins/*`:
- **`identity-x-authenticated`** — payload mirrors `global-event-emitter.js`, which sets
  `actionSource`, `loginSource` **and** `source` to the same value. GTM variables in the wild read
  whichever one they were written against; emitting only `actionSource` silently breaks the rest.
- **`identity-x-auto-signup`** — one event per `additionalEventData.autoSignups` entry, shape
  `{ entity, ...autoSignup, event }` per `global-auto-signup-event-emitter.js`. **This — not
  `identity-x-authenticated` — is what the sites' GA4 "subscribe" conversion triggers on.** The
  Omeda opt-in happens server-side either way, so a missing event here looks like "One Tap doesn't
  subscribe anyone" when the subscription exists and only the tracking is gone. Also mirrored to
  p1events as `Subscription / Subscribe`.
- **Navigation is deferred ~300ms** after the pushes. The email-link flow navigates in the same tick
  but does so from a fully-loaded `/user/authenticate`; One Tap can resolve far earlier in a page's
  life, before GTM has dispatched. `window.dataLayer` is also **assigned, not guarded** (`= … || []`)
  so a sign-in that beats a deferred container still queues its events instead of dropping them.

**Tags** (`components/marko.json`, config read from `req.identityX.config`):
- `<marko-web-identity-x-google-init>` — loads the GSI client + defines `window.handleGoogleOneTapCredential`;
  shows the One-Tap prompt when `autoPrompt`. The button depends on this being on the page.
- `<marko-web-identity-x-google-sign-in-button client-id= redirect-to= label=>` — the button; self-hides
  when unconfigured or `signInButtonEnabled` is false.

**Auto-rendering.** Both tags render inside `form-login.marko` + `form-authenticate.marko`, so the
login/authenticate pages get the button with **no per-site edit**. For the site-wide floating prompt
(`autoPrompt: true`), a site additionally drops `<marko-web-identity-x-google-init/>` once in its
document layout, gated on `!req.identityX.token`. The button tag can also be placed in a content
meter / gate.

**Telemetry.** Two seams. Neither needs site configuration:
- `config.addRequestAttributes` — called with a flat object as the route progresses
  (`idxGoogleStep`, `idxUserId`, `idxCreatedNewUser`, `idxRequiresUserInput`, `idxGoogleSurface`,
  `idxGoogleMissingFieldsBehavior`, `idxAutoSignupCount`, `idxImpersonateAttempts`,
  `idxNameBackfill`, `idxNameBackfillAttempts`). **Defaults to `utils/add-custom-attributes.js`**, which tags
  the current New Relic transaction, so the attributes ride on the `Transaction` *and* any
  `TransactionError` it produces — a 500 says which step failed instead of being a bare GraphQL
  message. That default resolves the agent **only if the app already loaded it** (checks
  `require.cache`, never requires it cold — requiring `newrelic` would *start* an agent on sites
  that don't use it), so it arrives fleet-wide on a dep bump and is inert everywhere else. Pass a
  function to override, or a no-op to disable. Deliberately **not** `noticeError`: that records a
  second error next to the one the error handler already captures and doubles the counts.
- `config.onHookError` — non-fatal errors, now called as `(error, customAttributes)` to match
  `newrelic.noticeError`. The Google `sub` linkage failures report through it with their step and
  user id, which keeps them distinguishable from fatal errors on the same route (they arrive on a
  200).

Client-side, `fail()` fires a p1events `Identity / Authenticate Failed` event mirroring the success
event. There is no New Relic Browser agent on the monorail sites, so without it a client-side
failure reaches no backend at all. The originating surface (`button` | `prompt`, from GIS
`select_by`) is posted to the route as `body.surface` — both surfaces otherwise share a route, a
`loginSource` and an impersonation method, so nothing server-side could tell them apart.

**Failure handling.** All client-side failures funnel through `fail()` in `google-init.marko`: it
always `console.error`s, writes to `#google-sign-in-error` when that element is on the page (it is
rendered by `google-sign-in-button.marko`, so it exists only where the button does), and otherwise
— the site-wide floating prompt case — redirects to the login endpoint with `?redirectTo=`. Never
let a failure be a silent no-op: GIS closes the prompt on its own, so the user reads "nothing
happened" as success and only discovers they're anonymous on the next page load.

**CSS.** `.google-sign-in` styles ship in `marko-web-theme-monorail/scss/components/_identity-x.scss`
(with the rest of the auth CSS) — no separate import.

**Logout.** `routes/logout.js` clears the GIS `g_state` cookie so a later sign-in with a *different*
Google account isn't blocked by stale auto-select state (harmless no-op when Google Sign-In is unused).

**Deliberate decisions / limitations.** `loginSource: 'google-one-tap'` and
`method: 'GOOGLE_ONE_TAP'` are emitted for BOTH surfaces (don't distinguish button vs prompt) —
kept for analytics/enum continuity. One success p1event (`Identity / Authenticate`, label
`Google Sign In` for the button via GIS `select_by` `btn*`, else `Google One Tap`), fired client-side
after `setIdentity`. `google-auth-library` is now a base-idx dependency (every site pulls it,
server-only) — accepted because the feature is gated on the client id. The Google OAuth app must
list each site origin as an authorized JavaScript origin or the surfaces silently no-show.

## Progressive Profiling — added 2026-08

Detects an **identified or authenticated** visitor with unanswered demographic attributes, gates
the article, and asks only the missing questions. The audience that matters is
*identified-but-not-authenticated* — the `__idx_idt` holder arriving from an email link, the
majority of who we know. Today those users' form answers go only to an anonymous content-access
submission and never touch their AppUser record (`form-access.marko:100-105` sets
`updateProfileOnSubmit = false`); this makes that write real.

**Dormant fleet-wide.** `progressiveProfileFieldRows` defaults to `[]`; nothing renders and no
shared behavior changes until a site declares fields *and* enables the theme's `progressiveProfile`
site config (Part 2/3, `marko-web-theme-monorail` + the website repos).

### The one hard constraint: attributes only

The backing mutation is `progressiveProfileUpdate` on **idx-compat**
(`mindful-reporting#263`, shipped 2026-08-05). It writes member **attributes** — select, boolean,
text — and nothing else; built-in/core member fields were cut on review. A built-in column would
render, accept a value, and then be silently unwritable: the user submits, gets `changed: false`,
and is re-gated forever. `config.js` therefore **throws at boot** on one, and on three other
configurations that are each a user-facing trap:

| Boot check | Why the runtime failure is worse than a failed deploy |
| --- | --- |
| `type: 'built-in'` | Unwritable ⇒ permanent gate loop. **Silent** — the only one of these with no visible symptom until a user is stuck. |
| `type: 'custom-boolean'` | The renderer bug that once made it unsatisfiable is fixed, but the gate forces every column `required: true`, and a *required* HTML5 checkbox can only ever be submitted checked — a progressive boolean could only ever record `true`, a toll rather than an answer. The profile form now uses an explicit yes/no control that would satisfy this (see *Boolean questions have three states*), but it is not wired into `custom-column.vue` and the check still stands — re-allow only once the gate actually renders that control. |
| any other type | Falls through to `custom-column.vue`'s `<pre v-else>` debug block, printing raw field metadata to a real visitor. |
| id missing from a non-empty `activeCustomFieldIds` | `routes/profile.js:88-104` silently discards the answer ⇒ gate loop. |
| rows configured with no `apiToken` | Every write uses `getOrgUserApiToken()` and the form token is signed from it. |

No coverage is lost: every field the fleet-wide impact analysis measured is a select attribute.

### Files

- `utils/get-missing-progressive-fields.js` — the diff. Matches on **`answer.field.id`**, never
  `answer.id`. That distinction is why this feature did not reuse `get-form-custom-fields.js`
  (or `config.getGTMUserData`) at ship time: that util keyed on the answer's own `id` and its
  truthy-empty-array check returned answer objects with no `field` — both fixed in
  mindful-web#324, but the dedicated util stays (it also forces every column `required: true` in
  the `missingRows` it returns, so `custom-column.vue` needs no changes).
- `utils/get-progressive-form-fields.js` — correct, feature-scoped replacement for
  `get-form-custom-fields.js`. Stamps `id` with the **field** id, which is the contract
  `custom-column.vue` reads and what `get-create-user-custom-fields.js` already emits.
- `utils/build-progressive-profile-payload.js` — marshals the body into mutation variables, plus
  `buildContentSource()`. **Definitions come from server config, never the body**, so a client
  cannot widen what gets written by posting extra rows. Drops an empty select rather than sending
  `optionIds: []`, which the writer reads as *unset* — the one way this feature could destroy data
  despite the fill-blanks-only rule.
- `routes/progressive-profile.js` — `POST /__idx/progressive-profile` (+ `/dismiss`). A thin proxy;
  it owns *trust*, not writes.
- `components/progressive-profile-form.marko` (`<marko-web-identity-x-progressive-profile-form>`)
  + `browser/progressive-profile.vue` (`IdentityXProgressiveProfile`, overridable via
  `CustomProgressiveProfileComponent`).
- `utils/progressive-profile-telemetry.js` — resolves the New Relic agent (injected first, lazy
  fallback) and exposes `createRecorder`, which emits one typed event per submit. See *Telemetry*.
- `utils/progressive-profile-cookies.js` — the attempts escape valve, the optional dismissal
  suppression, and the 3-day suppression that follows an unverified submit. **Not** bot safeguards;
  that is reCAPTCHA's job, below.

### Gate copy

Defaults live in `progressive-profile-form.marko`; a site overrides them per-instance with
`title` / `callToAction` (threaded through the theme's `progressive-profile-gate.marko`). The
theme middleware's Joi schema does **not** accept them, so they cannot go in a site's
`progressiveProfile` config without extending it first — and unknown keys there fail the boot by
design.

The default title names the number of questions (`One` / `Two` / `A few` … `quick question(s),
then keep reading`), read off `state.missingCount` so it reflects what is actually rendered, not
what is configured: a two-question site shows the one-question title to a reader who has already
answered half of it.

**Changing any default string is an i18n edit too.** `browser/progressive-profile.vue`'s
`translate()` map is keyed by the **English source string**, so a reworded default silently falls
back to English on pt/es sites — no error, no warning. Update the maps in the same change, and add
one key per count rather than interpolating the number: both translation layers here (the
app-level `i18n` function and `translate()`) are flat key lookups with no placeholder support.
Note the two strings translate by different paths — the title runs through `i18n()` *and*
`translate()`, while `callToAction` is rendered with `v-html` and so never reaches `translate()`.

### What happens server-side of the route

Everything, in one Mongo transaction: the re-diff that drops already-filled fields, the attribute
writes, the `action-history` entries attributed via `context.source = 'PROGRESSIVE_PROFILE'`, and
one `member-events` document of kind `PROGRESSIVE_PROFILE_SUBMIT`. The route does no
fetch-then-write of its own, and **no session is ever minted** — a visitor stays merely identified,
so a bot cannot come away holding one. There is no `actionId` client-side; attribution lives in
`action-history`.

Auth picks the target and the client never does: an authenticated visitor writes as themselves
(`AppUser` token, `userId` ignored by the resolver), an identified one is written by the org token
with the **resolved** `targetId` — `body.userId` is never trusted and a mismatch is a 403.

Fires `onUserProfileUpdate` only when `changed`, guarded, reported through `onHookError`. That hook
is **provider-agnostic** — Omeda rapid-identify is the most visible consumer but one of five
(`marko-web-identity-x-mailchimp`, `marko-web-postup`, plus site-level Braze and ActiveCampaign
packages). The `profileUpdateHooks` field records `dispatched | skipped | error`, and `dispatched`
deliberately does **not** mean any hook succeeded: every registrant uses `shouldAwait: false`, and
`utils/call-hooks-for.js` wraps each promise in `.catch(onHookError)`, so `callHooksFor` returns
before the hooks run and essentially never rejects. An async failure is reported there and can never
reach this field; `error` is a *synchronous* dispatch throw only. Deliberately **not** `onLoginLinkSent` / `onAuthenticationSuccess`: those drive
auto-signup and the GA4 `subscribe` conversion, and this is not a registration.

**The hook payload strips `customBooleanFieldAnswers` — load-bearing, do not "restore" it.**
The omeda consumer (`marko-web-omeda-identity-x/rapid-identify.js`) maps *every* boolean answer on
the user it receives into provider writes: product-namespace booleans become
`subscriptions: [{ id, receive }]`, deployment-type booleans become opt-ins/opt-outs. idx-compat
surfaces every email/**magazine** channel as a boolean field whose "answer" is the member's stored
subscription state — `active: false` reads as an explicitly-answered no (`hasAnswered` is
`answer != null`; channels have no unanswered state). This form cannot collect booleans
(boot-validation forbids the rows), so any boolean reaching the hook is state the visitor never
touched. Passing them through let a progressive submit overwrite Omeda subscription state
wholesale: 18 of the first 48 production submitters on athleticbusiness had their print-magazine
subscription killed (`Receive: 0`, "Controlled Kills") at their exact submit second — the
2026-08-19 incident. The spec asserting the payload shape lives in
`marko-web-omeda-identity-x/test/rapid-identify.spec.js`. The profile route (`routes/profile.js`)
still passes booleans deliberately — its form renders the subscription questions — but it now only
passes the ones the member has actually answered; see *Boolean questions have three states*.

### Bot protection — verify, then discard the write rather than the visitor

The threat is concrete: `marko-web-omeda-identity-x/middleware/set-identity-cookie.js` mints
`__idx_idt` for a **real person's** AppUser id on any request carrying `oly_enc_id`, so a newsletter
security scanner following an emailed link browses *as* an identified human and could write to a
real record. Cloudflare on these sites segregates rather than drops and scores 30–99 reach the
origin, so there is no upstream to lean on.

Verification is the fleet standard — **reCAPTCHA v3 via `@mindful-web/marko-web-recaptcha`**, the
same package, 0.5 floor and client shape as newsletter signup, contact-us, inquiries and the RigDig
checkout. Action `progressiveProfileSubmit`, declared client-side and checked server-side so a token
minted for another form cannot be replayed here.

**What differs is the response to a failure: the write is dropped, never the visitor.** The
submission "succeeds", the content unlocks, and the answers go nowhere. Every other form on the
fleet returns a hard 400 the visitor can do nothing about, which is right when the worst case is a
retried newsletter signup. Here the form is the only way past a gate on the article, so rejecting a
false positive locks a real reader out of content they can otherwise reach — a bigger loss than the
bot write it prevented. A bot gains an article it could largely have read anonymously, and gains no
write, which is the only thing actually at stake.

An earlier revision escalated to a reCAPTCHA v2 checkbox instead. Removed: it cost a second key pair
maintained fleet-wide plus a chunk of client code, to solve a problem **nobody has evidence exists**
— it is unknown whether newsletter scanners submit forms at all, or only load pages. Measure first
(below); the code is recoverable from `0f35bb1` if the data ever justifies it.

**Every failure mode passively accepts** — `low-score`, `no-token`, `bad-token`, `stale-token`,
`wrong-action` — each recorded separately. `no-token` matters as much as the rest: it is what an ad
blocker eating `api.js` produces, and the client deliberately **does not disable submit** when the
script fails to load (as the other forms do) because that would strand a reader behind a gate with
nothing to click.

**One rule for every visitor.** A failed check drops the write whether or not a session is present.
An earlier revision exempted session holders — `/user/profile` has no captcha either, so a session
is already sufficient authorization for a profile write elsewhere in this package — but that is a
user-initiated edit on a page someone navigated to, while this is an interstitial pushed at a
link-follower, which is exactly what an email scanner hits. Scanners follow magic links, so a
session is not proof of a human. The exemption also bought nothing: the score is recorded either
way, so authenticated submits still serve as the known-human control group.

**The suppression cookie is mandatory, not a nicety.** A discarded submit leaves the member's record
unchanged, so the diff would gate them again on the very next article — forever. `__idx_pp_sup`
(3 days, `progressiveProfile.unverifiedSuppressDays`) stops that and hands the visitor back to the
content meter, exactly as dismissal does. It is deliberately a **different cookie** from
`__idx_pp_dis`: "user skipped" and "we threw their answers away" need to be distinguishable in
Part 2's debug output and in support.

**The honest cost, and why the telemetry ships with this and not after.** A false-positive human
fills the form in good faith, nothing is saved, and three days later they are asked again —
permanently, invisibly to them *and to us*. The v2 challenge let them rescue themselves; this does
not. So the score is recorded on **every** submit, passing or not:
**one custom event per submit, typed by what happened** — `ProgressiveProfileSubmit` (the mutation
ran), `ProgressiveProfileDiscarded` (passive accept), `ProgressiveProfileRejected` (401/403/400,
carries `reason`) or `ProgressiveProfileError` (threw, or fell through unrecorded). Shared
attributes: `contentId`, `ms`, then `recaptcha`/`recaptchaScore` from the verify step and
`userId`/`auth` once the identity resolves; `Submit` adds `fieldCount`, `changed`,
`profileUpdateHooks`. Never the submitted answers.

Split by type rather than an `outcome` field so each outcome is independently countable and
alertable — "how many passive submits" is a bare `count(*)`, not a filter. There is deliberately no
`outcome`/`accepted` attribute; the type carries it, and two encodings drift. (The HTTP response
still returns `accepted` — that is the dataLayer's contract, unrelated.)

**The arithmetic is enforced, not left to discipline.** Each terminal branch records its own type,
`createRecorder` makes `record` a no-op once anything has fired, and a `finally` backstop catches a
path that recorded nothing — as `ProgressiveProfileError` with `reason: 'unrecorded'`, so a branch
added without a call site surfaces in New Relic instead of quietly skewing the mix.

**Why a custom event and not transaction attributes — and specifically not because of sampling.**
Measured before switching: these apps run 165–588 transactions/minute against a ~10k reservoir,
`Transaction` data is queryable 35 days back, and `idxGoogleStep` attributes land and query fine
today. Every New Relic destination is reservoir-backed anyway (`transaction_events`,
`custom_insights_events`, `application_logging.forwarding`), so there is no unsampled path to move
to; `recordLogEvent` would have been worst of all here, since every Log record on this account
arrives via the HTTP Log API with no `entity.guid` — no APM agent forwards application logs, so it
would likely have no-opped. The real reasons: the decision rule needs `histogram(recaptchaScore)`
and a dedicated event type is the right shape for it, `FROM Transaction WHERE name LIKE '%…%'` is a
string match a route rename silently breaks, and the measurement stops depending on `Transaction`
retention staying where someone set it. **Do not "fix" this back to `addCustomAttributes` on the
grounds that sampling was never a problem — it wasn't.**

**Deliberately unsampled**, unlike `MindfulApiCall`, which needed a hand-written sampler at ~40M
events/month. This is one row per gate submission: `count(*)` is the true count and there is no
`sampleRate` attribute.

The agent comes from `req.app.locals.newrelic` when a site passed one to `startServer`
(mindful-web#314), falling back to the shared lazy resolver in `@mindful-web/utils` — the option is
new and **no site passes it yet**, so without the fallback this would go dark fleet-wide. That
fallback is also why `utils/add-custom-attributes.js` is now a one-line delegation to
`newrelicAgent.addCustomAttributes`: it used to carry its own line-for-line copy of the same agent
resolution. `routes/google.js` is unchanged and keeps its step attributes on the transaction, where
`idxGoogleStep` riding on `TransactionError` is what makes a 500 self-describing.

Capturing the score required a small addition to the shared package: `verifyToken()` returns
`{ success, score, action, hostname, errorCodes }` instead of throwing, and `validateToken` is now a
thin policy layer over it with an unchanged contract. Reach for `verifyToken` when a caller needs to
*decide*; `validateToken` remains right for a form that should just refuse. **Two traps if you touch
it:** `science-medicine-group-websites`'s preference center echoes `e.message` verbatim to the
browser, so those three strings are user-facing copy; and `fusable-websites/packages/payfabric`
catches on `error.code`, which `http-errors` does not set, so a rejection there surfaces as a 500 —
adding a `.code` would silently flip it to 400. There is also a live regression trap inside
`validateToken` itself, commented at the call site: the original compared `undefined < minimumScore`
against the raw body, so a success carrying no score *passed*; `verifyToken` normalizes that to
`null`, and `null < 0.5` is true.

**The decision rule.** Run these after launch (`~/.newrelic/nrql`, account 2723345) and act on them;
a rejection rate is unactionable without the distribution behind it.

```sql
-- The mix. Rates span types with a comma-union, which NRQL supports natively.
SELECT count(*) FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded,
  ProgressiveProfileRejected, ProgressiveProfileError FACET eventType() SINCE 1 day ago

-- Passive submits, and why.
SELECT count(*) FROM ProgressiveProfileDiscarded FACET recaptcha SINCE 1 day ago

-- The discard rate the rollout table keys on.
SELECT percentage(count(*), WHERE eventType() = 'ProgressiveProfileDiscarded')
FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded SINCE 1 week ago

SELECT histogram(recaptchaScore, 1.0, 10)
FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded SINCE 1 week ago

-- Authenticated submits are the known-human control group: if their p5 sits below the
-- threshold, the threshold is wrong, not the traffic.
SELECT percentile(recaptchaScore, 5, 25, 50)
FROM ProgressiveProfileSubmit, ProgressiveProfileDiscarded FACET auth SINCE 1 week ago
```

| Discard rate | Score shape | Action |
| --- | --- | --- |
| < 2% | any | Done. Passive accept is permanent. |
| 2–10% | clustered below ~0.2 | Bots. Keep passive accept. |
| 2–10% | clustered 0.3–0.5 | Lower `recaptchaMinimumScore`, re-measure. Far cheaper than a challenge. |
| > 10% | any | Build the v2 escalation — `0f35bb1`. |

**What actually bounds the damage** is not on this route and never was: the mutation re-diffs inside
its own transaction and can only ever fill a field that was already empty, so nothing that gets past
reCAPTCHA can destroy data. Every write also lands in `action-history` with its `input`/`result`, so
a bad one is findable and reversible from one collection.

**Escape valve.** `progressive-profile-cookies.js` tracks gate renders; the theme middleware
increments and releases the content past `maxAttemptsPerUser`, and this route clears the counter on
any success. The known cause of an unclearable gate is gone structurally, but a gate that can trap a
real person is worse than no gate.

### Telemetry & analytics

One custom event per submit, typed by outcome — `ProgressiveProfileSubmit` / `Discarded` /
`Rejected` / `Error`. Shared attributes `recaptcha`, `recaptchaScore`, `auth` (`org|user`; `FACET`
it to see how much traffic is identified-but-not-authenticated, the population the feature exists
for), `userId`, `contentId`, `ms`; `Submit` adds `changed`, `fieldCount`, `profileUpdateHooks`. See *Bot protection* above for why this is an event rather than transaction
attributes, and for the decision rule it feeds. Operational only — **submission counts come from
`member-events`.**

The one thing still on the transaction is `onHookError`'s payload
(`idxProgressiveProfileStep`, `idxUserId`), which rides on `noticeError` — a separate seam, and the
right place for it: a non-fatal hook failure belongs next to the error it describes.

The dataLayer bridge and the p1events bridge are independent subscribers to the same EventBus, so
what reaches each is decided separately:

- **p1events: form views only.** A dedicated branch in `marko-web-p1-events/browser/index.js`
  (`Identity / View / Progressive Profile`, top-level content `entity`, `props` carrying
  `audience`, `fieldCount`, `fieldKeys`). No submit event: `member-events` is written by the
  mutation that did the work, inside its transaction, so no event means no write. Nothing is added
  to `identifyOnSubmit` — the session is already identified before the form renders.

  **Consequence of passive accept:** a discarded submit writes no `member-events` row, so the
  abandonment funnel (view identities minus `PROGRESSIVE_PROFILE_SUBMIT`) counts it as abandonment.
  Arguably correct — no data was collected — but "never submitted" and "submitted and we threw it
  away" are different populations, and only the New Relic `unverified` count separates them.
- **GA4/dataLayer keeps a submit event**, carrying server-truth `changed` so the conversion trigger
  can filter on `changed === true`. A discarded (unverified) submit reports `changed: false`, so it
  is excluded from completions with **no GTM change**; the accompanying `accepted` value
  (`written|unverified`) just makes discards separately countable. GA4 will read **lower** than
  `member-events` (blockers, bot filters, unload races); the direction is always undercount.
  Reconciling them is a watchlist item, not a bug.

**Trap:** `global-event-emitter.js` assigns `additionalEventData` *after* spreading `data`,
clobbering anything the caller passed under that key — which is why `access.vue`'s `ent:` never
reaches the bridge. The content entity is passed as `emit`'s third argument instead.

Submitted **answer values are never tracked** anywhere — `fieldKeys` and the member-event's
`fieldIds` record which questions were asked, nothing more.

### Follow-ups — both resolved 2026-08-15

1. ✅ **Index drop/recreate, production** — done. Both `completed_auth_*` indexes in production
   carry `PROGRESSIVE_PROFILE_SUBMIT` in their partial filters (verified on `audience:abmedia`
   and `audience:transpire`).
2. ✅ **The shared-code PR** — landed as mindful-web#324 (this package: `get-form-custom-fields.js`
   field-id keying, `custom-column.vue` boolean binding, `routes/profile.js` allowlist,
   `generate-required-field-payload.js` boolean branches + `hasAnswered` false-boolean fix) and
   mindful-web#325 (the p1events bridge's `contentGatingType`→`contentGateType` rename — the gate
   type had never reached p1events on any conversion event). The allured copied-meter frozen-clock
   bug went separately as allured-business-media-websites#461. Progressive-profile field rows
   still exclude `custom-boolean` — no longer for the renderer bug, but because the gate forces
   required columns and a required checkbox can only submit `true` (see the boot-check table).

## Fleet / rollout

Not tracked in-repo. The Google-auth architecture change + fleet rollout plan lives outside the repo
at `../GOOGLE-AUTH-ROLLOUT.md` (mindful working dir).
