Skip to content

Deterministic Omeda customer matching on rapid identification - #43

Merged
brandonbk merged 3 commits into
mainfrom
feat/deterministic-customer-matching
Aug 26, 2026
Merged

Deterministic Omeda customer matching on rapid identification#43
brandonbk merged 3 commits into
mainfrom
feat/deterministic-customer-matching

Conversation

@brandonbk

@brandonbk brandonbk commented Aug 26, 2026

Copy link
Copy Markdown
Member

The problem

rapidCustomerIdentification posts to Save Customer and Order with email only
(Emails: [{ EmailAddress }]). Matching is Omeda-side and heuristic, and payloads it cannot
confidently match mint duplicate customers.

Measured on athleticbusiness before writing any of this, because the working assumption was that
recent payload fixes (ab-media-websites#539, mindful-web#326) had already solved it. They had not:

Window (athlcd) Identified emails Dupe minted in window Rate
Aug 1 → Aug 21 04:54Z (pre ab#539) 335 9 2.7%
Aug 21 04:54Z → Aug 23 (post) 35 3 8.6%

Splitting by identification kind, with allured as a control:

Population Dupes / emails Rate
athlcd — emails with a PROGRESSIVE_PROFILE_SUBMIT 8 / 91 8.8%
athlcd — login/auth identification only 3 / 284 1.1%
allucd — all identification (allured has no progressive profiling) 3 / 1621 0.2%

The mechanism, confirmed

Attribution is causal. Matching each duplicate's Omeda CreatedDate against the member's event
timeline: 8 of 11 were minted at the exact second of a progressive-profile submit (brestevens —
event 15:44:42.454Z, customer 1108473175 created 15:44:42).

The duplicates are empty shells. 7 of 8 have no name, no company, no address, while the record
they forked from often has all three (austokel → "Coopersville Parks and Recreation";
asiqueiros → "City Las Vegas Dept Parks Recreation"). A shell is what Omeda writes when the
payload carried an email and nothing else to match on.

Why the payload has no contact fields — and it is not a bug. From member history (austokel;
tyler and asiqueiros identical):

Time Event
15:41:15.740Z member created — givenName: null, familyName: null
15:41:15.798Z first encrypted Omeda id written
15:42:43.711Z the PP submit — a duplicate is minted
16:10:02.959Z 28 min latergivenName → "Austin", familyName → "Stokel", backfilled by the Omeda→idx resync

At submit time the member genuinely had no name, company or address. The ActiveUserFragment
requests every contact field, on-user-profile-update.js passes user through, and the site
formatter does { ...data } — all three were checked and are correct. The fields did not exist yet.
This is structural: the progressive-profile audience is identified-but-not-authenticated members
created seconds earlier from an email link, whose IdentityX record is email-only by construction.

But the encrypted customer id is already stored, ~88 seconds before the submit. It is the only
identifying data those payloads can carry.

The change

encryptedCustomerIds: [String!] on RapidCustomerIdentificationMutationInput — every id the
caller holds for the brand. Each is resolved to its canonical active numeric id; when they agree,
that id is sent as SCAO's top-level OmedaCustomerId"providing this guarantees that identity
resolution processing will be bypassed."

input.encryptedCustomerIds[]
  └─ resolve each: lookupByEncryptedId({ reQueryOnInactive: true, errorOnNotFound: false })
       ├─ survivors agree on ONE active customer ──► SCAO body + OmedaCustomerId  (matchedBy: customerId)
       ├─ survivors DISAGREE (live duplicate pair) ─► email-only body             (matchedBy: email)
       └─ none active / >4 candidates / any error ──► email-only body             (matchedBy: email)
                                                      + noticeError
SCAO rejects the id (merge landed between lookup and post) ──► retry ONCE without it

Why a list, and why convergence is the rule. Members accumulate ids because IdentityX storage
appends, and those sets are two different things (measured, 40 sampled multi-id members on abmedia):

Kind Share Treatment
stale id + the survivor it merged into — converge once resolved ~32% use it — there is one answer, nothing to choose
two simultaneously active customers — a real duplicate pair 27 / 40 fall back to email — any choice decides which record receives every future write

lookupByEncryptedId does the merge-walking already, recursing on the "valid but not active …
please use Y"
404 transitively.

"Conclusively dead" is narrower than "did not resolve", and the distinction is load-bearing. A
candidate may be skipped only when it is known to name no active customer:

Candidate outcome Treatment Why
malformed (not 15 chars) skip, report can never be a customer id; filtered before any call
not found skip under errorOnNotFound: false a real 404 resolves empty — that is a definitive answer
errored (timeout / 5xx / transport) abort, fall back to email a real 404 does not throw here, so a throw means we do not know what that id points at — it could be a second active customer

So a live id alongside a dead one resolves; a live id alongside an errored one does not. Letting
a sibling's answer stand in for an unresolved id would be the exact guess this avoids, and the
asymmetry justifies the strictness: falling back costs one identification a little determinism, while
guessing writes the member onto a record that may not be theirs, permanently. Pre-filtering malformed
values is what makes this separable — validation would otherwise throw inside lookupByEncryptedId,
indistinguishable from a transport failure.

Callers must not pre-select an id, and specifically must not "use the newest". Position carries
no information — stored order is not creation order (13 matching vs 14 differing) and $setUnion
does not guarantee ordering. And newest inverts the right answer: among divergent pairs the older
record is the richer one 9 times to 2, because the newest id is typically the empty shell this bug
just minted while the oldest is the member's real customer.

Capped at 4 candidates and refused beyond rather than sampled — each is a live Omeda GET on the
blocking path of authentication, and resolving an arbitrary subset would reintroduce the guess.
Lookups run concurrently. Divergence is reported with both customer ids, which is what identifies
the pairs needing an Omeda-side merge.

The lookup is deliberately uncached. This service builds its api client with no cache and it
must stay that way: a cached pre-merge record would return exactly the stale id the resolution
exists to replace.

Failure is never fatal. This mutation is on the blocking path of authentication on every fleet
site, so a stale, merged, malformed or unknown id must never break identification. Joi rejects a
malformed id before any HTTP call (encrypted ids are exactly 15 chars); a hard 404 under
errorOnNotFound: false resolves empty rather than throwing, so the absent data.Id is the
signal — the same guard customerByEncryptedId already uses; anything else is caught.

Emails stays unconditional — email is required input and still updates the record's email list,
it just no longer drives matching.

matchedBy (customerId | email) is added for adoption/fallback observability and reports what
Omeda actually matched on: the retry path sets it back to email.

storeCustomerAndOrder posts an unvalidated body, so no api-client change was needed.

Tests

The repo had no test harness at all (test was yarn lint in every package). Stood up mocha+chai
in services/graphql and extracted the resolve logic into a testable module. 13 specs: single
live id; merge chain; convergence across candidates; dead candidate ignored; two converging plus a
third dead id still resolves
; malformed id dropped without a call and without vetoing a live
sibling
; live id + errored id refuses; lone errored id falls back; divergence refused, naming
both customers; none-active; dedupe; over-cap refused without any call; empty input makes no call and
reports nothing.

  • yarn test in services/graphql — lint clean, 6 passing
  • Schema builds; RapidCustomerIdentificationMutationInput.encryptedCustomerId and
    RapidCustomerIdentification.matchedBy verified present via introspection

Ship order — load-bearing

This must be tagged and deployed before the mindful-web client change publishes. An old service
rejects unknown input fields and would fail the whole mutation.

No version bump is included here, matching this repo's convention of a standalone version
commit on main (56097e3 = v1.9.0). After merging:

  1. bump lerna.json + services/graphql/package.json to 1.10.0
  2. push the v1.10.0 tag — that tag is what triggers the deploy
    (.github/workflows/deploy-graphql.yml on v*) and opens the gate for the client

The client side is parameter1/mindful-web#331. It is safe to merge at any time — its gate is the
1.88.0 version commit, which must wait for the tag above. The change is additive (an optional input
field with a default, plus an additive output field), so existing clients are unaffected and no
parallel mutation or schema versioning is needed; #331's body records why in full.

Follow-ups (deliberately not here)

  • Cleanup of the members whose ids genuinely diverge: Omeda-side merges of the duplicate pairs
    (the divergence noticeError names both customer ids), plus replace-semantics for idx external
    ids so a post-merge write-back retires the stale id. 527 abmedia / 2,932 allured members hold 2+
    ids today; ~32% are already rescued by convergence here, and the population stops growing once
    this ships, since it is generated by the bug itself.
  • Once adoption is confirmed via matchedBy, progressive-profile submits can stop sending contact
    fields entirely, completing the principle established in mindful-web#329. That is only safe
    after this ships — contact fields are currently the sole match key on that path.

🤖 Generated with Claude Code

`rapidCustomerIdentification` posts to Save Customer and Order with email only. Matching is
Omeda-side and heuristic, and payloads it cannot confidently match mint duplicate customers.

Measured on athleticbusiness (Aug 2026): 8 of 11 duplicates were created at the *exact second*
of a progressive-profile submit, and 7 of 8 were empty shells -- no name, no company, no address.
That is what Omeda writes when the payload carries an email and nothing else to match on. The
progressive-profile audience is identified-but-not-authenticated members created seconds earlier
from an email link, whose IdentityX record is email-only by construction, so their payload can
never carry contact fields. Their encrypted customer id, however, is already stored -- written
within a second of member creation, ~88 seconds before the submit.

Adds an optional `encryptedCustomerId` input. When supplied it is resolved to the canonical,
currently-active numeric id and sent as SCAO's `OmedaCustomerId`, which bypasses identity
resolution entirely.

Merge-resilience lives here rather than in each caller: Omeda merges duplicates routinely, so a
stored id may point at a record that has been merged away. `lookupByEncryptedId` already follows
those chains transitively, so one live lookup yields the survivor. The lookup is deliberately
uncached -- a cached pre-merge record would return exactly the stale id this resolution exists to
replace.

Every failure mode falls back to today's email-only behaviour and is reported, never raised: a
stale, merged, malformed or unknown id must never break identification, which sits on the blocking
path of authentication on every fleet site. Joi rejects a malformed id before any HTTP call; a hard
404 under `errorOnNotFound: false` resolves empty rather than throwing, so the absent `data.Id` is
the signal; anything else is caught. A rare lookup-to-post race -- a merge landing between the two
-- is caught at the SCAO call and retried once without the id.

`Emails` stays unconditional. Email is required input and still updates the record's email list;
it just no longer drives matching.

Also adds `matchedBy` (`customerId` | `email`) for adoption/fallback observability, reporting what
Omeda actually matched on -- the retry path sets it back to `email`.

Stands up mocha+chai in services/graphql (the repo had no test harness) for the resolve module:
live id, merge chain, hard 404, malformed id, transport error, and absent id.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@brandonbk
brandonbk force-pushed the feat/deterministic-customer-matching branch from d1d32ac to 4445f60 Compare August 26, 2026 02:56
brandonbk and others added 2 commits August 25, 2026 22:11
Takes `encryptedCustomerIds: [String!]` instead of a single id, resolves each, and uses the
result only when the survivors agree on one active customer.

Members accumulate ids because storage appends, and those sets are of two kinds that need
opposite treatment. Measured on abmedia (40 sampled multi-id members):

- ~32% converge -- a stale id plus the survivor it was merged into. `lookupByEncryptedId`
  recursion collapses both onto one record, so there is nothing to choose and the id is safe.
  The previous single-id contract forced callers to send nothing here, stranding roughly a third
  of multi-id members on email matching for no reason.
- 27 of 40 resolve to *different active* customers -- a genuine duplicate pair, not a merge.
  Choosing decides which record receives every future write, so these still fall back to email.

Dead candidates are ignored rather than disqualifying: a merged-away or unknown id alongside a
live one leaves exactly one real answer.

Why callers must not pre-select an id. Position carries no information -- stored order is not
creation order (13 matching vs 14 differing) and `$setUnion` does not guarantee ordering. And
"newest" is actively wrong: among divergent pairs the older record is richer 9 times to 2,
because the newest id is typically the empty shell this bug just minted while the oldest is the
member's real customer.

Capped at 4 candidates, refused beyond rather than sampled -- each is a live Omeda GET on the
blocking path of authentication, and resolving an arbitrary subset would reintroduce the guess.
Lookups run concurrently. Divergence is reported with both customer ids, which is what identifies
the pairs needing an Omeda-side merge.

10 specs (was 6): single live id, merge chain, convergence, dead-id ignored, throwing-id ignored,
divergence refused, none-active, dedupe, over-cap, empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rored

A candidate may be skipped only when it is known to name no active customer. Two qualify:
malformed (not 15 chars, so it can never be a customer id -- now filtered before any call), and
not-found (under `errorOnNotFound: false` a genuine 404 resolves successfully with an empty body,
so an absent `data.Id` is a definitive answer).

An error is neither. Precisely because a real 404 does not throw here, a throw means timeout, 5xx
or transport failure -- we do not know what that id points at, and it could be a second active
customer. The previous revision treated it as dead and let a sibling's answer stand in, which is
the exact guess this function exists to avoid. Now any errored candidate aborts the resolution and
falls back to email matching.

The asymmetry justifies the strictness: falling back costs one identification a little determinism,
while guessing writes the member onto a record that may not be theirs, permanently.

Pre-filtering malformed values is what makes this separable at all -- validation used to throw
inside `lookupByEncryptedId`, indistinguishable from a transport failure.

13 specs (was 10). New: two converging plus a third dead id still resolves; a malformed id is
dropped without a call and without vetoing a live sibling; a live id plus an errored id refuses;
a lone errored id falls back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@brandonbk
brandonbk merged commit 0ac1c64 into main Aug 26, 2026
5 checks passed
@brandonbk
brandonbk deleted the feat/deterministic-customer-matching branch August 26, 2026 11:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant