Decision: the response follows the durable account state
- Account commit
- Absent, committed, or unknown after a timeout. This decides whether registration itself failed.
- Verification artifact
- Not attempted, store failed, stored, or handed to a sender. This decides whether verification can proceed.
- Email outcome
- Sender acceptance, provider events, receiver acceptance, and inbox visibility remain later evidence.
Invariant: under an optional-verification policy, a committed usable account means registration succeeded. Token-store or email failure must not rewrite that fact as registration_failed.
Browser-local decision tool
Map the response and recovery state
Select bounded evidence only. No email address, username, password, token, session, log, credential, domain or account data is requested or transmitted.
- Policy
- Account
- Verification
- HTTP contract
- Recovery
- Safety rule
Three policies need three different invariants
- Required. Keep the usable account behind confirmation. A durable pending registration may return 202, but no password session or active account exists until verification succeeds.
- Optional. Commit the account as the primary result. Verification can be pending, retry-scheduled, temporarily unavailable, or complete without changing registration success.
- None. Do not let token storage or mail delivery participate in registration success at all.
A truthful operation contract
POST /registrations Idempotency-Key: random-client-operation-id
account commit rolled back:
503 { registration: "not_created", retry: "same_key" }
account commit outcome unknown:
202 { registration: "reconciling", operationRef }
optional account committed; token store or sender failed:
202 { registration: "created",
verification: "retry_scheduled" | "unavailable",
operationRef }
required pending registration stored:
202 { registration: "pending_verification", operationRef }
Invariant: a retry with the same key returns or resumes
the same logical registration.
Use bounded states and opaque operation references. Do not return a provider error, raw token, recipient address, password hash, Redis key, or internal account identifier to the client.
Implementation order
- Give each logical registration an idempotency key and persist its operation state.
- For optional verification, commit the account and a durable verification job in the same primary transaction when possible.
- Let a worker create or rotate the single-use verification artifact, call the sender, and record bounded attempt state. Every retry targets the same account and logical verification job.
- Return account creation independently from verification delivery. Emit metrics and logs for token-store and sender failures instead of swallowing them.
- Provide a rate-limited resend or resume path that operates on the existing unverified account.
- Track a provider message ID and later delayed, bounced, rejected, complained, or receiver-accepted events without rewriting account state.
A compensating delete is a narrower fallback, not atomicity. It is safe only before the account becomes externally visible, for the exact user ID created by this operation, with tests for crashes and concurrent login. A durable operation plus outbox avoids asking cleanup to erase an already-observable success.
What the current AuthKit source proves
open-rails/authkit issue 56 reports a live Optional-policy failure where Redis rejected the verification-token write after the account had already been created. In reviewed master revision dbd0071021d23ef1412c3878fdffc31ba002cfdb, the Optional email branch calls createEmailRegistrationUser(), which commits its PostgreSQL transaction, before storeEmailVerificationTokens() and SendVerification().
Both later operations return errors to the HTTP handler. That handler maps an unclassified error to registration_failed; however, a successful Optional path immediately reads the committed account and issues its registration tokens because only the Required policy sets requiresVerification. The failure response therefore contradicts the same branch's account-availability contract.
The smallest behavioral correction is to treat token-store and sender failures after the Optional account commit as verification outcomes, preserve telemetry, and let the handler complete registration. The stronger design commits an idempotent verification job with the account and gives retries a stable operation identity.
Failure tests before release
- Force the verification store to fail after the Optional account commit. Prove the response says created, one account exists, authentication follows Optional policy, and one recovery job is recorded.
- Force the sender to fail after token storage. Prove registration remains created, the token is not exposed, and resend resumes the same account.
- Drop the HTTP response after commit, then replay the same idempotency key. Prove the original result returns and no duplicate account or message is created.
- Run two concurrent retries. Prove one logical registration and one active verification artifact.
- Under Required policy, fail the pending-state write. Prove no usable account or password session exists.
- Return sender success and then ingest a bounce. Prove verification delivery changes while registration state does not.
Inspect public SPF, DKIM, DMARC, PTR and MTA-STS without entering a recipient, token, password or mailbox credential.
Primary references
- AuthKit issue 56: Optional verification leaves a functional account behind a 500
- AuthKit Optional registration branch at the reviewed revision
- AuthKit HTTP policy and error response branch at the reviewed revision
- RFC 9110: HTTP 202 Accepted
- PostgreSQL: transactions are all-or-nothing within the database boundary