ADR-0035: Self-service DM-code identity verification; the webhook external_user_id is the authoritative binding key
Status
Proposed (Phase 1.5 — pending design approval, depends on ADR-0031)Tags
digital-worker, identity, verification, onboarding, whatsapp, telegram, securityDecision
A Gremlin user binds their own external chat identity by DMing the worker a short-lived, single-use code instead of an admin sourcing and asserting an opaque platform ID by hand. The inbound webhook envelope carries the authoritativeexternal_user_id, which becomes the binding key written into dw_external_identities. No human ever reads or copies a platform user ID.
- The user is steered into the DM by a panel-issued code, a click-to-chat deep link, or a QR; the platform — not a human — supplies the identity on the resulting message.
- The output is the same verified
dw_external_identitiesrow Phase 1 already trusts (UNIQUE (organization_id, provider, external_user_id),status='verified'). Only enrollment changes; the security gate (ADR-0029) is untouched. - v1 scope is Telegram + WhatsApp only — both ingest through the existing webhook path, so the new logic lives in one place. Discord stays admin-assert because its ingest is Gateway-only (ADR-0023), not the webhook path.
- Admin assertion remains a valid fallback; this is an additional enrollment path, not a replacement.
Why
Admin assertion stacks three error-prone steps: source the opaque ID from the platform, match it to the right Gremlin user, with no feedback loop to catch a mistake. Inverting the flow removes the step most likely to be done wrong — the platform gives us theexternal_user_id, and completing the DM challenge is itself the success signal. It reuses the entire Phase 1 ingest path rather than inventing new machinery.
Trusting the webhook-supplied external_user_id (not anything the user types) is what makes this safe: the ID is asserted by the platform’s authenticated transport, so a user cannot claim to be someone else by typing a different name or number.
Rejected alternatives:
- Admin-only assertion as the sole path. The current pain — manual, unverifiable, no feedback. Kept as a fallback, not the default.
- OAuth-style platform login. No usable per-user OAuth flow returns the bot-scoped
external_user_idwe key on. Rejected. - Trusting display names / phone numbers typed by the user. Forbidden by the Phase 1 security model — spoofable. Rejected.
Consequences
- Enrollment becomes self-service and auditable end-to-end (issue → consume → verified row), enabling roster fan-out and live “N of M verified” onboarding.
- A new
dw_verification_codestable and a pre-identity resolution branch are introduced (mechanics in ADR-0036). - Discord users still require admin assertion until a separate Gateway/slash-command path is built.
- The residual exposure is a leaked single-use invite link, bounded by short TTL and one-time use — the same trust model as a click-to-verify email; invites go out through the panel/email, never as bot-initiated messages.
Rules for agents
- The binding key is ALWAYS the
external_user_idfrom the webhook envelope — never a name, handle, or number the user typed. - Self-service verification MUST produce the same
verifieddw_external_identitiesshape Phase 1 trusts; do not weaken the uniqueness/verified invariant. - v1 self-service is Telegram + WhatsApp only; Discord remains admin-assert.
- Verification establishes who an identity is, never what it may do — capabilities and approval gates are unchanged (ADR-0029, ADR-0032).