Identity Canonicalization

Reconcile alternate platform address forms to one stable identity

Some platforms address the same person with two interchangeable ids — WhatsApp uses both <lid>@lid and <phone>@s.whatsapp.net. Identity canonicalization maps every alternate form to one stable id before the gateway keys the session, memory, allowlist, and pairing on it, so one person is never split into two principals.

Diagram

Identity resolution

📨 raw address\n(LID or phone)

🪪 canonicalize

🧍 one stable id

🔑 session · memory\nallowlist · pairing

Quick Start

  1. WhatsApp: nothing to configure

    WhatsAppBot wires WhatsAppIdentityCanonicalizer on the web-mode message path automatically. LID and phone forms of the same person resolve to one identity — no flags, no config.

    bash
    praisonai bot whatsapp --mode web
  2. Implement it for another platform

    The extension point is a dependency-free, runtime-checkable Protocol. Implement canonicalize and return the stable id — or the raw id unchanged when you don’t know it.

    python
    from praisonaiagents.gateway import IdentityCanonicalizerProtocol
    
    
    class MyPlatformCanonicalizer:
        def __init__(self):
            self._map: dict[str, str] = {}
    
        def canonicalize(self, platform: str, raw_user_id: str) -> str:
            # Return the stable canonical id, or raw_user_id when unknown (fail-open).
            return self._map.get(raw_user_id, raw_user_id)
    
    
    # Runtime-checkable — no base class to inherit
    assert isinstance(MyPlatformCanonicalizer(), IdentityCanonicalizerProtocol)
  3. Wire it into the session for any platform

    Canonicalization runs at the general session-key path (BotSessionManager._storage_key()), so every platform benefits — not just WhatsApp. Inject a canonicalizer at runtime through GatewayRuntimeSeams.

    python
    from praisonaiagents.bots import GatewayRuntimeSeams
    from praisonai_bot.bots._session import BotSessionManager
    
    session = BotSessionManager(platform="myplatform")
    session.attach_gateway_runtime(
        GatewayRuntimeSeams(identity_canonicalizer=MyPlatformCanonicalizer())
    )
  4. Use the adapter as its own canonicalizer

    BasePlatformAdapter now ships a default canonicalize() (identity function), so any adapter structurally is an IdentityCanonicalizerProtocol. Override it to reconcile alias formats, then hand the adapter itself to identity_canonicalizer=.

    python
    from praisonaiagents.bots import BasePlatformAdapter
    from praisonaiagents.gateway import IdentityCanonicalizerProtocol
    from praisonai_bot.bots._session import BotSessionManager
    
    
    class MyPlatformAdapter(BasePlatformAdapter):
        def canonicalize(self, platform: str, raw_user_id: str) -> str:
            # override to reconcile alias formats; default is identity
            return self._alias_map.get(raw_user_id, raw_user_id)
    
    
    # A vanilla BasePlatformAdapter subclass is already a valid canonicalizer.
    adapter = MyPlatformAdapter()
    assert isinstance(adapter, IdentityCanonicalizerProtocol)
    
    # Pass the adapter itself where the protocol is accepted.
    session = BotSessionManager(
        platform="myplatform",
        identity_canonicalizer=adapter,
    )

How It Works

Canonicalization is the first step of identity resolution. Every downstream key — session, memory, allowlist, pairing — is derived from the reconciled id, so alternate address forms of the same person collapse to one principal.

Diagram
Session / memory / allowlist / pairingCanonicalizerPlatform wireSession / memory / allowlist / pairingCanonicalizerPlatform wirealt[mapping known][unknown (fail-open)]canonicalize(platform, raw_user_id)stable canonical idraw_user_id unchanged

The contract is deliberately small and safe:

  • Deterministic. The same input always maps to the same output.
  • Fail-open. An unknown address returns unchanged, so a missing mapping is never worse than passing the raw id through. With no canonicalizer registered, resolution is unchanged.
  • First in line. It runs before any key is derived, so the allowlist and session see the reconciled id — not the raw one.

Order of operations in _storage_key()

The session-key path stabilises the id first, then resolves it:

  1. canonical_user_id = self._canonical_user_id(user_id) — runs first, fail-open on error/empty.
  2. base = identity_resolver.resolve(platform, canonical_user_id) — runs on the stabilised id.
  3. base becomes the session key.
Diagram
_storage_keyidentity_resolveridentity_canonicalizerPlatform wire_storage_keyidentity_resolveridentity_canonicalizerPlatform wirecanonicalize(platform, raw_user_id)canonical_user_id (fail-open on error)unified session id

WhatsApp implementation

WhatsAppIdentityCanonicalizer learns the LID↔phone mapping from whatsmeow’s SenderAlt alternate-JID field and reconciles the sender toward the phone form. It only accepts an exact @lid ↔ @s.whatsapp.net pairing — a group @g.us, an unexpected domain, or an LID-to-LID pair is rejected, so a malformed or hostile alternate JID never stores a bogus identity.

BehaviourResult
LID form of an allowlisted phone number arrivesMatches the allowlist (reconciled to phone form)
LID-form and phone-form turns from one personOne session and memory bucket
Already-paired user arrives in the other formNo re-pairing prompt
LID-addressed self-chatStill detected as self-chat, not dropped as outgoing
Unknown or malformed alternate JIDReturned unchanged (fail-open)

See WhatsApp Bot → Identity: LID vs phone JID.


Protocol Reference

Import from the gateway package:

python
from praisonaiagents.gateway import IdentityCanonicalizerProtocol
MethodSignatureContract
canonicalizecanonicalize(platform: str, raw_user_id: str) -> strDeterministic; MUST return raw_user_id unchanged when no reconciliation is known (fail-open).

IdentityCanonicalizerProtocol is a @runtime_checkable Protocol, so any object with a matching canonicalize method satisfies isinstance(...) without subclassing.

BasePlatformAdapter now provides a default canonicalize() that returns the raw id unchanged (identity function). Any subclass therefore satisfies the protocol without an override — override only to add real reconciliation.


Wiring

Two entry points on BotSessionManager inject a canonicalizer.

Entry pointCallWhen
Constructor argBotSessionManager(..., identity_canonicalizer=...)Wire at build time.
Runtime injectionmgr.attach_gateway_runtime(GatewayRuntimeSeams(identity_canonicalizer=...))Wire (or replace) at runtime.

When both a canonicalizer and an identity_resolver are wired, the canonicalizer always runs first — the resolver sees the stabilised id.

Fail-open contract: any exception or empty result ("", None, or a non-string) falls back to the raw id. The failure is logged at warning level and never breaks session keying.

python
from praisonaiagents.bots import GatewayRuntimeSeams
from praisonai_bot.bots._session import BotSessionManager

# Constructor
session = BotSessionManager(platform="myplatform", identity_canonicalizer=canon)

# Runtime
session.attach_gateway_runtime(GatewayRuntimeSeams(identity_canonicalizer=canon))

Common Patterns

Learn a mapping, then canonicalize

python
from praisonaiagents.gateway import IdentityCanonicalizerProtocol


class AliasCanonicalizer:
    def __init__(self):
        self._alias_to_canonical: dict[str, str] = {}

    def learn(self, alias: str, canonical: str) -> None:
        self._alias_to_canonical[alias] = canonical

    def canonicalize(self, platform: str, raw_user_id: str) -> str:
        return self._alias_to_canonical.get(raw_user_id, raw_user_id)


canon = AliasCanonicalizer()
canon.learn("alt-123", "user-123")

assert canon.canonicalize("myplatform", "alt-123") == "user-123"
assert canon.canonicalize("myplatform", "unknown") == "unknown"   # fail-open
assert isinstance(canon, IdentityCanonicalizerProtocol)

Guard against wrong-shape addresses

Only reconcile pairs you positively recognise; return the raw id for anything else so a hostile or malformed address can’t hijack another user’s identity.

python
class StrictCanonicalizer:
    def canonicalize(self, platform: str, raw_user_id: str) -> str:
        if platform != "myplatform" or "@" not in raw_user_id:
            return raw_user_id
        local, _, domain = raw_user_id.partition("@")
        if domain != "alt.example":          # only this alternate domain maps
            return raw_user_id
        return f"{local}@canonical.example"

Best Practices

Always fail open

Return raw_user_id unchanged whenever you can’t map it. A missing mapping must never corrupt identity — it should be exactly today’s behaviour.

Canonicalize before every key

Reconcile the id first, then derive session, memory, allowlist, and pairing keys from the result. Canonicalizing after a key is derived reintroduces the split.

Accept only exact, known pairings

Validate the shape of both addresses before storing a mapping. Reject group, cross-type, or unexpected-domain pairs so a hostile alternate id can’t map onto a real user.

Keep canonicalize deterministic

The same raw id must always map to the same canonical id within a process. Non-deterministic output splits sessions and breaks allowlist matches.

The adapter is a canonicalizer by default

Because BasePlatformAdapter.canonicalize() exists, hand the adapter itself to identity_canonicalizer= instead of writing a separate class — do this when the alias mapping already lives on the adapter.


Related