ADR-0130: Code-first invites, piloted per property

Date: 2026-08-25 Status: Accepted — removal trigger executed in PR #7300 (code-first is now the default for everyone; the flag and /welcome are deleted) Owner: Fede

Context

During the 2026-08-24 CONAM property-manager onboarding, the invitee could not sign in for ~40 minutes: every click on her magic link showed error=INVALID_TOKEN. The reconstructed causal chain (SendGrid per-message click events, Better Auth session records, Vercel request logs):

  1. Magic-link tokens are single-use; the first click consumes them.
  2. The invitee's Microsoft 365 mailbox runs Defender, which machine-clicked every link 4× within seconds of delivery and re-scanned hourly all night (0 opens / 20–27 clicks per email — 69 machine clicks across 3 emails). The scanner's GETs did NOT consume tokens (the /auth/confirm POST-form pattern held), but they made the click data unreadable live.
  3. After the one click that DID work, the first-time user was routed to unannounced MFA setup, which both people on the call read as failure — driving the resend-and-reclick loop that burned the remaining links.
  4. A used link landed on a bare login form with no recovery path.

The structural problem: a single-use artifact in an inbox where machines click everything and humans forward and re-click. No amount of link-flow polish removes that class; a typed 6-digit code does, because scanners cannot type.

Decision

  1. Invitees of pilot properties get a code-first invite: a welcome email whose CTA is a plain, tokenless link to /welcome, where they request an emailed 6-digit code and type it. Nothing in the email is single-use.
  2. The pilot is gated by CODE_FIRST_INVITE_PROPERTY_IDS (comma-separated property ids; unset = off everywhere). First pilot: appfolio-45 (The Willows).
  3. The flow decision moves server-side (POST /api/admin/users/invite-email), with the same invite authority as the user-create route (requireUser + canInviteRole). The admin UI no longer fires auth emails from the browser.
  4. Two fixes ship unconditionally (not gated): the used-link recovery notice on /login?error=INVALID_TOKEN, and a double-submit guard on /auth/confirm.

The gate is a pilot with a removal trigger, not a permanent arm

Hot rule 13 ("no arms by default") is the objection this section answers. The flag exists because the owner explicitly requested a staged rollout on a real customer-facing auth surface ("implement the POC on The Willows and report back before we turn anything on", 2026-08-24). It is scoped to expire:

Alternatives considered

Verification

Real-network e2e on the preview deployment (2026-08-25, PR #6252): invite → SendGrid-delivered welcome email verified to contain zero tokens → /welcome → real OTP email → typed code → session → announced MFA setup. Plus 14 unit tests (flag parsing, flow selection, no-token-in-email assertion) and the adjacent middleware/login/confirm suites.