中文版

Digital Worker Phase 1.5 — Self-Service Identity Verification Design

A Phase 1.5 self-service identity verification flow: a verified Gremlin user binds their own external chat identity by DMing a short-lived, single-use code to the worker on Telegram or WhatsApp, and the inbound webhook supplies the authoritative external_user_id so no one copies an opaque platform ID. It replaces admin-by-hand ID sourcing while producing the same verified dw_external_identities row, leaving the Phase 1 security gate unchanged.

View source markdown ↗ generated by claude-opus-4-8 · diagrams mermaid

Summary

Self-service identity verification inverts who does the work: instead of an admin sourcing opaque platform IDs by hand, the user proves ownership by DMing the worker a short-lived, single-use code, and the inbound webhook supplies the authoritative external_user_id. The output is the same verified dw_external_identities row Phase 1 already trusts — only enrollment changes, the security gate does not.

  • Phase 1.5 — promotes the deferred Phase 1 DM-code challenge
  • Providers (v1) Telegram (gold path) + WhatsApp; Discord stays admin-assert
  • New surface One table + one resolution branch; no new transport
  • New ADRs 0035 · 0036

Scope & goals

In scope

v1
  • Telegram + WhatsApp only — both ingest through the existing webhook path, so the branch lives in one place.
  • dw_verification_codes table and lifecycle (issue → pending → consumed / revoked).
  • Per-provider initiation: panel code, click-to-chat deep link, QR.
  • Pre-identity webhook resolution (sender is not yet mapped).
  • Codes-only bulk pre-provisioning + live "N of M verified".
  • Self-explaining first contact: one safe, rate-limited reply.

Out of scope

deferred
  • Discord self-service — Gateway-only ingest (ADR-0023), not the webhook path; needs a separate Gateway/slash-command branch plus guild/DM reachability. Stays admin-assert.
  • Changing the verified-identity gate itself — unchanged.
  • Proactive (unsolicited) verification nudges — consequential, gated.
  • Replacing admin assertion entirely — remains a valid fallback.

Success criteria

  • A user with no prior mapping self-verifies in one tap, no ID copying.
  • An admin fans out invite links/QRs to a roster and watches state advance live ("7 of 12 verified").
  • Every issued code, consumption, expiry, and failure is auditable.
  • An unverified messager is told how to verify, without leaking whether any identity is known.
  • No path produces a verified row without a live, unconsumed, correctly-bound code.

Key decisions

Self-service DM-code challenge replaces ID sourcing (ADR-0035)

The user sends a token to the worker; the webhook envelope carries the authoritative external_user_id. No human ever reads or copies an opaque platform ID.

Admin assertion stacks three hard things — source the ID, match it to a user, no feedback loop. Inverting removes the part most likely to be done wrong: the platform gives us the ID, and completing the challenge is itself the success signal. Reuses the entire Phase 1 ingest path.

Rejected: admin-only assertion as the sole path (current pain); OAuth-style platform login (no usable per-user OAuth returns the bot-scoped external_user_id); trusting display names (forbidden by the Phase 1 security model).

The code is the only new secret; resolution happens pre-identity (ADR-0036)

Verification matches in a distinct branch before identity resolution, because the sender is unknown_user at that moment. A matched code is consumed atomically and writes the verified row in the same transaction.

Putting the branch after identity resolution would default-deny every attempt. The code must be single-use and consumed in the same TX that writes the identity, or two concurrent DMs could double-bind.

Rejected: resolve identity first then special-case failures (noisy denials); long-lived reusable codes (replay / sharing); deriving the code from user data (guessable).

Provider matrix — how initiation differs

The resolution is identical once a message lands; only the way a user is steered into the DM and how much can be prefilled varies.

ProviderDeep linkCode visible?Notes
Telegram (gold path) t.me/<bot>?start=<payload> No — opaque payload Cleanest UX: ?start= carries an opaque single-use token, delivered as /start <payload>. User never sees a code.
WhatsApp wa.me/<E164>?text=<prefilled> Yes (prefilled, never typed) Body pre-typed with a namespaced sentinel (e.g. GV-<code>); user only taps send. Inbound arrives on the same webhook + HMAC path.
Discord (deferred) Slash command / DM the bot Yes No true prefill. Gateway-only ingest (ADR-0023) + guild-membership requirement → admin-assert is the v1 default.

Data model

dw_verification_codes

One row per issued challenge, binding an opaque high-entropy token to exactly one Gremlin user, one org, and (optionally) a target provider.

ColumnTypeNotes
iduuid PK
org_idtexttenant scope
code_lookuptextthe only secret-derived column: HMAC-SHA256(token, server_pepper). Match on this. Raw token never stored or logged; pepper is a server-side config secret (same pattern as the AES-GCM key), so a DB-only compromise can neither reverse nor brute-force it.
gremlin_user_idtextthe user this code verifies on consume
role_agent_iduuid NULLprovenance/filter only — feeds the wizard's per-role-agent counter. Does not scope the resulting identity (which is org-level).
target_providertext NULLCHECK IN (whatsapp,telegram) or null = any
statustextCHECK IN (pending,consumed,revoked). No stored expired — expiry is time-derived, so no sweeper/cron.
expires_attimestamptzshort TTL from a module-wide DigitalWorkerConfig default of 15 min; per-org TTL deferred.
consumed_at / consumed_external_user_id / consumed_providernullableset atomically on success; the platform-delivered ID is recorded for audit
attempt_countintper-code replay signal only — a random wrong guess matches no row, so this is not the brute-force control.
issued_by / created_attext / timestamptzadmin/user who issued it; self if user-requested

Indexes: UNIQUE (code_lookup); (org_id, gremlin_user_id, status); partial index WHERE status = 'pending' for the resolver hot path.

Atomic consume

Success is a single guarded UPDATE that flips pending → consumed and, in the same transaction, inserts the identity row. Zero rows updated = failed attempt.

UPDATE dw_verification_codes SET status='consumed', consumed_at=now(),
       consumed_external_user_id=$uid, consumed_provider=$provider
WHERE code_lookup=$1 AND org_id=$worker_org AND status='pending'
      AND expires_at>now() AND (target_provider IS NULL OR target_provider=$provider)
RETURNING gremlin_user_id, role_agent_id;

dw_external_identities — dedicated insert branch

After a successful consume returns U, with (org, provider, Y) from the envelope, the branch resolves the existing mapping first:

Existing (org,provider,Y)Action
absentINSERT verified U↔Y — the only path that writes a row
maps to Uidempotent success; retire the code; no mutation
maps to V ≠ Utakeover guard: no mutation, flag for admin, retire the code, generic failure reply

If U already holds a different verified external id for this provider, treat as flag-for-admin / no auto-add (possible account change). Structural backstop: ON CONFLICT DO NOTHING.

Pre-provisioning — codes-only

Issuance authorization

A code is a bearer token that, on consume, writes (consumed_external_user_id → gremlin_user_id) — so whoever controls gremlin_user_id at issue time controls which chat account can become which Gremlin user.

Self-issue (issued_by='self')

Any authenticated org member, but the server forces gremlin_user_id to the caller's own user id and rejects any attempt to name another user. You can only ever bind your own identity.

Issue-for-another (roster / bulk)

Requires org owner/admin or the existing CanManageOrg-style scope. gremlin_user_id is the selected target member; issued_by is the issuing admin, who vouches the link reaches the right person.

The residual "admin issues for V, link leaks to an attacker" exposure is exactly the accepted TTL + single-use bearer-link risk (same trust model as a click-to-verify email), which is why invites go out through the panel/email, never as bot-initiated messages.

Resolution logic — webhook to verified row

In EventProcessor.Process the branch slots after the !res.NeedsAIPath early-return and before policy.Evaluate. That placement buys three properties for free:

  1. Audited first

    ingest.Ingest has already written the received dw_chat_events row, so the verification DM is logged before the branch touches it.

  2. Two races, two guards

    A re-delivered same DM is filtered as Duplicate (transport idempotency); the atomic DB consume independently guards the different-message-same-code race.

  3. Intercept before default-deny

    The not-yet-mapped sender never reaches policy.Evaluate, which would otherwise correctly deny them.

The logic lives behind an optional verification dependency on EventProcessor (nil disables, mirroring sender/learning). New IngestDecision terminal values verified and denied_verification keep verification DMs out of the decision IN ('received','processed') extraction/context windows.

Pre-identity verification branch and its fall-through to the unchanged Phase 1 pipeline.
Pre-identity verification branch and its fall-through to the unchanged Phase 1 pipeline.

Deciding "is this a verification attempt?" — cheaply, before any AI

  • Telegram — the message is literally /start <payload> (or /verify <code>); deterministic string match, the payload is the candidate token.
  • WhatsApp — the prefilled body carries a namespaced sentinel prefix, so a plain text DM is distinguishable. Free-typed look-alikes simply fail the atomic consume — safe by construction.
  • Discord (deferred) — would be a /verify slash command or a sentinel-prefixed DM, but Discord self-service is out of scope (Gateway-only ingest).

Slotting into the safe-vs-consequential model (ADR-0029)

The DM-challenge is composed entirely of safe-executable actions — the user initiated it, and the worker is not acting on the org's behalf.

StepClassificationWhy
User sends the verification DM— (inbound)Solicited via a link the org issued.
Worker consumes code + writes verified identitysafeA self-binding the user proved entitlement to; changes identity mapping, not access/capabilities.
Worker replies "verified" / "code invalid"safeA solicited, in-channel reply to a message the user just sent.
First-contact "I don't recognize you, verify here"safeSolicited, generic, rate-limited, leaks nothing.
Proactively DMing a user who never contacted the workerconsequentialUnsolicited outbound on the org's behalf — gated / out of scope. Invites go through the panel.

The crucial line: verification establishes who a chat identity is; it never establishes what they may do. Capabilities, approval gates, and AccessGate behavior are untouched — a freshly self-verified user can do precisely what their Gremlin role allows, no more.

Onboarding surfaces (panel)

Sequenced by leverage-per-effort:

  1. Per-user invite link / QR

    On the channel-binding or member screen, generate a code and get a wa.me / t.me?start= link plus QR (Telegram/WhatsApp only; Discord uses admin-assert). The backbone.

  2. Bulk "invite the team"

    Fan out pre-provisioned links to the known roster; the screen shows live verification state per member.

  3. Self-explaining first contact

    Wire the unverified-solicited reply so every failed interaction becomes a recovery path.

  4. Guided binding wizard

    Walk create-role-agent → soul → bind-channel → capabilities → verify-members with a live "N of M verified" counter.

Error handling

CaseBehavior
Code valid, atomic consume hitsFlip to consumed, insert verified identity in same TX, safe success reply, audit.
Code expired / already consumed / revokedZero-row update → attempt_count++, denied event, generic rate-limited hint.
Provider mismatchTreated as a miss; generic hint. (Prevents a Telegram code being used from WhatsApp.)
(org,provider,external_user_id) already verified to the same userIdempotent: success reply, no duplicate row (ON CONFLICT DO NOTHING); consume the code to retire it.
… already verified to a different user (Case C)Takeover guard: no mutation, flag for admin, retire the code, generic failure reply.
Same user already mapped on this provider with a different external id (Case D)Flag for admin (possible account change); do not auto-add a second binding.
Rate limit exceeded for a senderWindowed count of denied-verification events over threshold → stop replying; keep recording denied events.
Secret/credential-looking token sent as a codeNever matched, never stored as anything but a failed attempt; never logged in plaintext.
Unverified user mentions worker in a channel (not DM)Default-deny per Phase 1; optional single safe hint to DM-verify, debounced per user per channel.

Safety & security

  • Output is still a verified row. Nothing weakens UNIQUE (organization_id, provider, external_user_id) + status='verified'; only enrollment changed.
  • Store only the keyed hash. Persist a single code_lookup = HMAC-SHA256(token, server_pepper) and match on it; never store the raw token, a second hash, or log either.
  • Single-use, short-TTL, atomic consume. Closes replay and the concurrent double-bind race.
  • No oracle. Every failure returns the same generic message. Per-sender rate limit is a windowed COUNT of denied-verification dw_chat_events keyed on (org_id, provider, external_user_id) (e.g. >5 in 10 min) → suppress the reply. No new table; reuse the events already written, with a supporting index if hot.
  • Self-binding ≠ self-elevation. Verification grants identity, not capability (ADR-0029/0032).
  • Reuse the gate downstream. The moment the row exists, the Phase 2 auto-save poisoning defense and Phase 3 owner-coercion rule apply automatically.
  • Audit everything. Issue/consume/revoke and every failed attempt land in audit per ADR-0007 (human actions → audit_log; observability → dw_chat_events).

Rollout plan

Every step is reversible and additive; none alters the Phase 1 gate.

  1. Schema + resolver

    Add dw_verification_codes, the atomic-consume branch, the pre-identity webhook check. Telegram /start first.

  2. WhatsApp initiation

    wa.me prefill sentinel (Discord stays admin-assert).

  3. Invite link / QR

    Per-user link + QR in the panel; success/failure safe replies.

  4. First contact

    Unverified-solicited hint, rate-limited.

  5. Bulk + live state

    Roster fan-out, "N of M verified".

  6. Guided wizard

    Fold the above into an ordered setup flow.

New ADRs introduced

ADRDecision
0035Self-service DM-code challenge replaces admin ID-sourcing; the platform-supplied external_user_id in the webhook envelope is the authoritative binding key.
0036Verification resolves in a pre-identity branch with atomic single-use code consumption; failures are generic (no oracle) and rate-limited. Extends the ADR-0029 safe-set with three reply actions to unverified senders, gated by verification/solicitation context.

Builds on: ADR-0023 (single-owner lease, Discord Gateway), ADR-0029 (safe-vs-consequential + verified identity), ADR-0031 (direct provider integration), ADR-0032 (identity/memory is context not authority), ADR-0007 (two audit systems).

Open questions