ADR-0036: Verification resolves pre-identity with atomic single-use code consumption; generic no-oracle failures; extends the ADR-0029 safe-set
Status
Proposed (Phase 1.5 — pending design approval, depends on ADR-0035)Tags
digital-worker, identity, verification, security, atomicity, rate-limiting, safe-executableDecision
The verification check runs in a distinct branch before Phase 1 identity resolution, because at that moment the sender isunknown_user. A matched code is consumed atomically and writes the verified identity row in the same transaction. In EventProcessor.Process the branch slots after the !res.NeedsAIPath early-return and before policy.Evaluate, behind an optional verification dependency (nil disables it, mirroring sender/learning).
- Atomic consume: a single guarded
UPDATE … 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 …. Zero rows updated = failed attempt. The identity is inserted in the same TX. - org_id in the predicate closes cross-org by construction — the DM is already org-scoped by the
worker_idit arrived on (a worker is single-org), so a code issued for org A is literally unconsumable through any other org’s worker. - Dedicated insert branch, never the admin
Upsert. ItsON CONFLICT … DO UPDATE SET gremlin_user_idwould let a code bound to userUoverwrite an existing(org,provider,Y)→Vmapping and handV’s identity toU(silent takeover). Self-service usesON CONFLICT DO NOTHINGand resolves the existing mapping first (absent → insert; maps toU→ idempotent; maps toV≠U→ takeover guard, flag for admin, no mutation). - Only the keyed hash is stored.
code_lookup = HMAC-SHA256(token, server_pepper); the raw token is never stored or logged. Status ispending|consumed|revokedwith no storedexpired— expiry is time-derived (no sweeper). - No oracle. Every failure returns the same generic “invalid or expired” reply. Per-sender rate limiting is a windowed COUNT of
denied_verificationdw_chat_eventskeyed on(org_id, provider, external_user_id)— no new table. - New
IngestDecisionterminal valuesverifiedanddenied_verificationkeep verification DMs out of thedecision IN ('received','processed')extraction/context windows.
reply-on-mention capability, and bounded by four controls: user-initiated only, fixed templated text (no AI, no oracle), per-sender rate limiting, and per-user-per-channel debounce.
Why
Placing the branch after identity resolution would default-deny every attempt, because the sender is not yet mapped. Single-use must be enforced in the same transaction that writes the identity, or two concurrent DMs bearing the same code could double-bind. The placement also buys three properties for free: the DM is audited first (ingest.Ingest already wrote the received row), there are two races guarded by two mechanisms (transport idempotency filters a re-delivered same DM; the atomic DB consume guards the different-message-same-code race), and the not-yet-mapped sender is intercepted before default-deny.
The verification replies must be called out explicitly because ADR-0029’s safe reply-on-mention is narrow — a reply to a verified user in an allowlisted channel, gated by a per-channel capability. Verification replies satisfy none of those (recipient unverified, no allowlisted binding, no channel capability), so they are genuinely new safe actions whose gate is the verification/solicitation context. Recording it deliberately prevents a future reader from inferring a broader “reply to anyone” capability.
Rejected alternatives:
- Resolve identity first, then special-case the failure. Produces noisy denials and still needs the pre-identity data; simpler to branch before resolution. Rejected.
- Long-lived / reusable codes. Enable replay and sharing. Rejected in favor of short-TTL single-use.
- Confirming whether a code/identity exists (an oracle). Leaks enumeration signal. Rejected — all failures are identical.
- Pre-created
pendingidentity rows for bulk invites. Structurally impossible: there is no realexternal_user_iduntil the DM arrives, so the row’s key column would be NULL/placeholder. Pre-provisioning is codes-only.
Consequences
- A successful verification is a safe action that posts to an untrusted external surface unreviewed — bounded by the four controls above and the
digital_workerkill-switch (ADR-0016), consistent with ADR-0029. - Expiry needs no cron; the resolver’s partial index
WHERE status='pending'carries the hot path. - Failed attempts are recorded as
denied_verificationevents and feed the rate limiter; a supporting(org_id, provider, external_user_id, created_at)index is optional if the count gets hot. - The takeover guard and “different existing external id” case (flag-for-admin) require admin follow-up surfaces, not silent mutation.
Rules for agents
- The code consume MUST be a single atomic guarded
UPDATEincludingorg_id = $worker_org, and the identity insert MUST share the same transaction. - NEVER reuse the admin identity
Upsertfor self-service; use a dedicated branch withON CONFLICT DO NOTHING, neverDO UPDATE gremlin_user_id. - Store only
code_lookup = HMAC-SHA256(token, server_pepper); never persist or log the raw token, and never store a derivedexpiredstatus. - Every verification failure returns the same generic message — no signal about codes, identities, or users.
- The three verification replies extend the ADR-0029 safe-set; they are user-initiated, fixed-text, rate-limited, and debounced. Proactively DMing a user who never contacted the worker stays consequential and out of scope.