Skip to Content
ConceptsChain of CustodyCross-Wallet Identity Consolidation

Cross-Wallet Identity Consolidation

Companion to Holder Authentication (session/login contract — unaffected in shape, just re-anchored) and Holder Claim Integrity, which this corrects: that page’s claim-hold account lookup was written as “if no account exists for this email, this is a brand-new identity.” That’s the wrong constraint — the fix is “if no person matches, this is a brand-new person.”

Why the current model is wrong, not just incomplete

  • A holder account’s email is unique, one row, one email, forever — email is simultaneously the login credential, the notification address, and the platform’s only notion of “who this is.”
  • A placeholder DID is computed deterministically from the email string alone, both server- and client-side, embedded directly into the signed credential at issuance. Two different emails for the same real person always produce two different placeholder subjects, by construction, before either credential is ever claimed.
  • A custodian’s own roster, and the onboarding-invite path, only ever check for an exact string match on email — a different email for the same human always reads as “not found,” and the registration/invite path proceeds to create a new identity.

The result: a person who interacts with two custodians under two different employer emails gets two wallets, two DIDs, two credential histories, with no structural way for the system to know they’re the same person — because nothing in the data model represents “the same person” as a concept independent of email.

The redesign: Person, Account, and Identifier are three different things

ConceptTodayRedesigned
Who this is (name, DOB, country, address, safety contact)Lives on the holder account, one row per emailMoves to core.persons — one row per real human, independent of how many emails/wallets they’ve used
How they log in (email + magic link/passkey, one DID)The holder account — conflated with identitycore.holder_accounts (slimmed): id, person_id FK, email, DID. Multiple accounts can point at the same person.
A fact the system knows before any wallet exists (an email a custodian typed, a name/DOB a document extracted)Nothing — no representation until an account is registeredcore.person_identifiers: person_id, email, extracted name/DOB, source (CUSTODIAN_INTAKE / SELF_REGISTERED), first-seen time. A person can exist from the moment any custodian’s intake first names them.

Crucially: a holder account and its DID stay exactly as cryptographically meaningful as they are today. A credential’s credentialSubject.id still points at one specific account’s DID, forever — nothing about issued credentials changes. What changes is that “your credentials” in the wallet becomes the union across every account sharing your person id, and the platform can now express “these two logins are the same human” as data.

What’s already correct and must not change

  • Issuance-time placeholder computation stays a pure, offline-capable function of the channel used at issuance. What’s new is a resolution layer behind it that reconciles different placeholders back to one person, wherever the evidence supports it — the signing path itself is untouched.
  • A W3C credential’s credentialSubject.id is fixed at issuance and this redesign cannot retroactively move any issued credential between accounts.
  • Every existing consent/visibility guardrail still applies at full strength — no cross-holder exposure, never a directory, no reading a profile without consent.
  • The existing claim-hold machinery is reused, not reinvented — it already solves “is this new fact consistent with an existing identity’s history, or does it need a human.” This applies that same machinery one layer up, to identity resolution itself.

Design guardrails

GuardrailMeaning
Automatic person-resolution is only ever safe on a proven identity matchThe only case that resolves without a human step is an exact, already-verified channel match — the identical email, or the identical real DID already recorded by a different custodian’s intake. A shared normalized name and DOB across two different emails is plausible, never proven — it must escalate, never auto-merge.
Resolution runs at ingestion, not at loginThe decision runs the moment a new identifying fact enters the system — custodian intake, self-registration, onboarding-invite completion — never deferred to a self-service “did you know you have two wallets?” flow.
An escalated match is resolved by proof, not by guesswork upgraded with more guessworkEither the person proves control of both channels (completing a channel’s own auth challenge), or an admin resolves it with actual evidence. No third path where the system decides on its own with a higher fuzzy-match score.
A person’s canonical profile lives in exactly one placeOnce core.persons exists, identity fields move there — never duplicated per account.
No credential, consent grant, or audit trail is rewritten by a resolution eventMerging two accounts under one person changes what a wallet UI shows together; it never rewrites a credentialSubject.id, a past consent grant’s scope, or a past audit log entry.
Custodian and admin surfaces stay read-only with respect to resolutionA custodian’s “Add person” flow never queries persons/identifiers directly and never sees a possible-duplicate list — resolution is a backend pipeline, not a tool exposed to a custodian.
Consent grants do not unify across linked accountsConsent stays keyed by holder_did, a third identity key independent of both account id and person id. Linking two accounts under one person never retroactively gives a custodian who only had a relationship with one account visibility into the other’s profile or credentials.
A resolution hold is never disclosed outside an authenticated session, and declining it never requires a reasonNo email/SMS announces a possible match — it surfaces only as an in-app notification to whoever is already signed in. Declining is instant and permanent with no justification collected, since someone may deliberately keep two identities apart for a real safety reason.

Phase 1 — introduce Person and Identifier; re-anchor accounts

core.persons holds the canonical profile; core.person_identifiers records every email a custodian or a self-registration has ever associated with a person, tagged by source and first-seen time — unique on email only where the source is a verified self-registration, since a custodian-intake email string is corroborating evidence, not exclusive proof of ownership. core.holder_accounts gains a person_id FK (backfilled 1:1 on migration — every existing account becomes its own person) and drops the identity fields it used to own.

Every read site that touches an identity field directly on the account entity is repointed through the account’s person relation rather than rewritten call-by-call — this keeps the existing deterministic name-matching engine (reused unchanged by Phase 2 below) trivial to carry forward, and keeps a handful of other call sites (passkey display name, the admin holder registry listing, a custodian evidence request) to a fetch-strategy change rather than a rewritten query.

Phase 2 — resolution pipeline at ingestion

A new service resolves identity at the three points a fact enters the system: custodian intake extraction, holder registration, and onboarding-invite completion.

Evidence tiers matter. A verified self-registration email is proof of ownership and always resolves automatically, even when unrelated custodian-intake rows carry the same string. A custodian-intake-only email is corroborating evidence, not proof of exclusive ownership — if every matching intake row already agrees on one person, that agreement resolves automatically; if intake rows disagree across more than one existing person, it holds, since an identical string across two different people (a shared inbox, a reused placeholder, an extraction error) is a real possibility.

Registration is a special case: the email isn’t proven yet. A person record is created provisionally at registration; resolution runs only after the magic-link challenge actually completes, using the same tiered lookup as intake — never an insert-then-catch, since the identifier table’s uniqueness is intentionally partial (self-registered emails only), so a naive insert could silently create a second person for an email a custodian had already attested.

Concurrency. Two intake events for the same real person arriving at the same moment are a real race under this platform’s default isolation level. The verified-control path is already protected by the identifier table’s own partial unique index; the custodian-intake-only and plausible-match paths use a Postgres advisory lock keyed on the normalized signal (email, or name+DOB), held for the duration of the check-candidates → decide → create-or-hold transaction — serializing concurrent resolution attempts without ever constraining the fuzzy field itself.

Indexing. A trigram index on normalized name plus an exact date-of-birth pre-filter narrows the candidate set before running the actual pairwise name-similarity comparison — proportionate to this platform’s scale, no new infrastructure. Resolution runs as a fast asynchronous step after intake/registration, never inline in the interactive request path.

Phase 3 — resolving a held match: proof or admin evidence

A held match is resolved one of two ways, both reusing the existing claim-hold service rather than a parallel system:

  • Holder-proof path — the person is offered “we noticed you might already have an account — confirm by signing in to it.” Completing that channel’s own existing auth challenge (magic link or passkey) is the proof — no new authentication factor is invented. Surfaced in-app only, to whoever’s already authenticated.
  • Admin-evidence path — an admin resolves the specific held case citing actual evidence, never a bare “looks right to me” click.

A hold involving a minor never uses the holder-proof fast path — it always routes to admin-evidence, drawing on the guardian contact already captured at registration. Declining (“no, different people”) is instant, permanent, and unexplained, and writes a durable exclusion record checked before any future hold for that same pair.

Resolving “yes, same person” sets both accounts’ person id to the same value immediately — no credential, consent, or audit row is rewritten, only the account→person pointer changes — with both affected accounts notified in-app the moment it happens, and unlink available instantly to either side. A wrong merge is data-reversible (an admin can split a wrongly-merged person back apart at any time, audited both ways) but not exposure-reversible: a unified credential view already shown during a wrong-merge window can’t be un-shown, which is accepted as documented residual exposure, mitigated by logging the exact moment each session first rendered it.

Phase 4 — unified credential view

Once resolution exists, the wallet’s credential list becomes, structurally, every credential across every account sharing the signed-in person’s id — extending the existing per-custodian tab pattern with a per-account dimension where more than one account exists under one person.

A holder who has both an active claim hold and an active identity-resolution hold at once sees one aggregated status view listing each pending item with its own explanation and action, rather than two banners to reconcile — a read-side composition layer only, since the two hold systems stay deliberately separate underneath.

Phase 5 — periodic re-scan for missed matches

Deterministic matching has a false-negative rate: OCR-extracted names carry typos, and a one-shot match at ingestion leaves a permanently missed match sitting unresolved forever. A low-priority background job re-runs only the fuzzy name/date-of-birth comparison (cases 3/4 in Phase 2) for identifiers not yet scanned — never re-litigating an already-declined pair, and never revisiting exact-email or DID resolution — on a schedule, not real-time.

The Core Engine scheduler runs one bounded batch per hour by default (UTC, cron and batch size configurable with ATTESTPRO_PERSON_RESOLUTION_RECONCILIATION_CRON and ATTESTPRO_PERSON_RESOLUTION_RECONCILIATION_BATCH_SIZE; default batch size 50, maximum 500). core.person_identifiers.reconciliation_scanned_at is the durable per-row checkpoint. New identifiers resolved synchronously by ingestion are marked scanned in that same transaction; older or independently-added rows start pending. Each batch locks pending rows in first_seen_at, identifier_id order with FOR UPDATE SKIP LOCKED, rechecks candidate pairs against the permanent NOT_MATCHED exclusions, and commits its hold creation and scan marker together. Rows added after a batch’s selection remain pending for a later pass; concurrent schedulers claim disjoint rows. A failed batch rolls back its markers and holds, logs the error, and retries those rows on the next scheduled run. Repeated candidate holds are idempotent.

The low-cardinality Micrometer counter attestpro.person.resolution.events, tagged only with outcome=existing_person|new_person, measures successfully completed intake/registration resolution events. existing_person counts verified-email, DID, and Tier-2 resolutions; new_person counts an explicit CREATED_PERSON result. Held, failed, skipped, already-recorded, and permanently-declined outcomes are excluded from both counts. No identifying values are metric labels, and the metric does not influence resolution decisions.

What this changes in Holder Claim Integrity

Holder Claim Integrity’s claim-hold account lookup now resolves through this page’s PersonResolutionService rather than a direct email-lookup: a claim can be blocked on an identity-resolution hold, a credential hold, or both at once, surfaced together per this page’s unified status view. Its earlier “no account for this email → brand-new identity” language is corrected to “no person match → brand-new person.”

Deferred to a separate workstream: GDPR / right-to-erasure semantics

Not decided here — bounded and finalized alongside the platform’s separate legal/compliance sign-off workstream. The working bound: an erasure request against one account retires that account’s login/DID and account-specific data; the shared person profile persists only while another live account still depends on it, and cascades fully if it was the person’s only account or the request explicitly covers every linked account. Anything in an append-only audit log is tombstoned (fields replaced, row kept) rather than deleted.

Explicitly out of scope: within-custodian roster duplicates

A custodian’s own roster entering the same real worker twice under two different email addresses is a separate, purely within-custodian data-entry problem — see Custodian Workforce Compliance — unrelated to the cross-custodian wallet fragmentation this page solves, and not incidentally fixed by it.