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.
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.
Scope & goals
In scope
v1- Telegram + WhatsApp only — both ingest through the existing webhook path, so the branch lives in one place.
dw_verification_codestable 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
verifiedrow 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.
| Provider | Deep link | Code 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. |
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.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
org_id | text | tenant scope |
code_lookup | text | the 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_id | text | the user this code verifies on consume |
role_agent_id | uuid NULL | provenance/filter only — feeds the wizard's per-role-agent counter. Does not scope the resulting identity (which is org-level). |
target_provider | text NULL | CHECK IN (whatsapp,telegram) or null = any |
status | text | CHECK IN (pending,consumed,revoked). No stored expired — expiry is time-derived, so no sweeper/cron. |
expires_at | timestamptz | short TTL from a module-wide DigitalWorkerConfig default of 15 min; per-org TTL deferred. |
consumed_at / consumed_external_user_id / consumed_provider | nullable | set atomically on success; the platform-delivered ID is recorded for audit |
attempt_count | int | per-code replay signal only — a random wrong guess matches no row, so this is not the brute-force control. |
issued_by / created_at | text / timestamptz | admin/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 |
|---|---|
| absent | INSERT verified U↔Y — the only path that writes a row |
maps to U | idempotent success; retire the code; no mutation |
maps to V ≠ U | takeover 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.
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.
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:
- Audited first
ingest.Ingesthas already written thereceiveddw_chat_eventsrow, so the verification DM is logged before the branch touches it. - 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. - 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.
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
/verifyslash 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.
| Step | Classification | Why |
|---|---|---|
| User sends the verification DM | — (inbound) | Solicited via a link the org issued. |
| Worker consumes code + writes verified identity | safe | A self-binding the user proved entitlement to; changes identity mapping, not access/capabilities. |
| Worker replies "verified" / "code invalid" | safe | A solicited, in-channel reply to a message the user just sent. |
| First-contact "I don't recognize you, verify here" | safe | Solicited, generic, rate-limited, leaks nothing. |
| Proactively DMing a user who never contacted the worker | consequential | Unsolicited 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:
- 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. - Bulk "invite the team"
Fan out pre-provisioned links to the known roster; the screen shows live verification state per member.
- Self-explaining first contact
Wire the unverified-solicited reply so every failed interaction becomes a recovery path.
- Guided binding wizard
Walk create-role-agent → soul → bind-channel → capabilities → verify-members with a live "N of M verified" counter.
Error handling
| Case | Behavior |
|---|---|
| Code valid, atomic consume hits | Flip to consumed, insert verified identity in same TX, safe success reply, audit. |
| Code expired / already consumed / revoked | Zero-row update → attempt_count++, denied event, generic rate-limited hint. |
| Provider mismatch | Treated as a miss; generic hint. (Prevents a Telegram code being used from WhatsApp.) |
(org,provider,external_user_id) already verified to the same user | Idempotent: 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 sender | Windowed count of denied-verification events over threshold → stop replying; keep recording denied events. |
| Secret/credential-looking token sent as a code | Never 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_eventskeyed 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.
- Schema + resolver
Add
dw_verification_codes, the atomic-consume branch, the pre-identity webhook check. Telegram/startfirst. - WhatsApp initiation
wa.meprefill sentinel (Discord stays admin-assert). - Invite link / QR
Per-user link + QR in the panel; success/failure safe replies.
- First contact
Unverified-solicited hint, rate-limited.
- Bulk + live state
Roster fan-out, "N of M verified".
- Guided wizard
Fold the above into an ordered setup flow.
New ADRs introduced
| ADR | Decision |
|---|---|
| 0035 | Self-service DM-code challenge replaces admin ID-sourcing; the platform-supplied external_user_id in the webhook envelope is the authoritative binding key. |
| 0036 | Verification 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).