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-executable

Decision

The verification check runs in a distinct branch before Phase 1 identity resolution, because at that moment the sender is unknown_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_id it 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. Its ON CONFLICT … DO UPDATE SET gremlin_user_id would let a code bound to user U overwrite an existing (org,provider,Y)→V mapping and hand V’s identity to U (silent takeover). Self-service uses ON CONFLICT DO NOTHING and resolves the existing mapping first (absent → insert; maps to U → idempotent; maps to V≠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 is pending|consumed|revoked with no stored expired — 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_verification dw_chat_events keyed on (org_id, provider, external_user_id) — no new table.
  • New IngestDecision terminal values verified and denied_verification keep verification DMs out of the decision IN ('received','processed') extraction/context windows.
This extends the ADR-0029 safe-set with three reply actions to unverified senders (verification success/failure replies and the first-contact “verify here” hint), gated by the verification/solicitation context rather than the per-channel 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 pending identity rows for bulk invites. Structurally impossible: there is no real external_user_id until 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_worker kill-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_verification events 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 UPDATE including org_id = $worker_org, and the identity insert MUST share the same transaction.
  • NEVER reuse the admin identity Upsert for self-service; use a dedicated branch with ON CONFLICT DO NOTHING, never DO UPDATE gremlin_user_id.
  • Store only code_lookup = HMAC-SHA256(token, server_pepper); never persist or log the raw token, and never store a derived expired status.
  • 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.