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, security

Decision

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 authoritative external_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_identities row 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 the external_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_id we 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_codes table 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_id from the webhook envelope — never a name, handle, or number the user typed.
  • Self-service verification MUST produce the same verified dw_external_identities shape 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).