Deterministic Omeda customer matching on rapid identification - #43
Merged
Conversation
`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
force-pushed
the
feat/deterministic-customer-matching
branch
from
August 26, 2026 02:56
d1d32ac to
4445f60
Compare
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>
This was referenced Aug 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
rapidCustomerIdentificationposts to Save Customer and Order with email only(
Emails: [{ EmailAddress }]). Matching is Omeda-side and heuristic, and payloads it cannotconfidently 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:
Splitting by identification kind, with allured as a control:
PROGRESSIVE_PROFILE_SUBMITThe mechanism, confirmed
Attribution is causal. Matching each duplicate's Omeda
CreatedDateagainst the member's eventtimeline: 8 of 11 were minted at the exact second of a progressive-profile submit (brestevens —
event
15:44:42.454Z, customer 1108473175 created15: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 thepayload 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):
15:41:15.740ZgivenName: null,familyName: null15:41:15.798Z15:42:43.711Z16:10:02.959ZgivenName → "Austin",familyName → "Stokel", backfilled by the Omeda→idx resyncAt submit time the member genuinely had no name, company or address. The
ActiveUserFragmentrequests every contact field,
on-user-profile-update.jspassesuserthrough, and the siteformatter 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!]onRapidCustomerIdentificationMutationInput— every id thecaller 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 identityresolution processing will be bypassed."
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):
lookupByEncryptedIddoes 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:
errorOnNotFound: falsea real 404 resolves empty — that is a definitive answerSo 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
$setUniondoes 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: falseresolves empty rather than throwing, so the absentdata.Idis thesignal — the same guard
customerByEncryptedIdalready uses; anything else is caught.Emailsstays 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 whatOmeda actually matched on: the retry path sets it back to
email.storeCustomerAndOrderposts an unvalidated body, so no api-client change was needed.Tests
The repo had no test harness at all (
testwasyarn lintin every package). Stood up mocha+chaiin
services/graphqland extracted the resolve logic into a testable module. 13 specs: singlelive 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 testinservices/graphql— lint clean, 6 passingRapidCustomerIdentificationMutationInput.encryptedCustomerIdandRapidCustomerIdentification.matchedByverified present via introspectionShip 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:lerna.json+services/graphql/package.jsonto 1.10.0v1.10.0tag — that tag is what triggers the deploy(
.github/workflows/deploy-graphql.ymlonv*) and opens the gate for the clientThe 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)
(the divergence
noticeErrornames both customer ids), plus replace-semantics for idx externalids 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.
matchedBy, progressive-profile submits can stop sending contactfields 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