# Console email sign-in (magic links), with Keymail found from the address

Status: rev 6, 2026-10-21 (approved rev 5 + the build's sign-in-flow change, §15). Follows
[console invite delivery](2026-09-28-console-invite-delivery-design.md),
whose §4 console mailer this reuses and whose line 4 named this spec.

## 1. Why

The console's only door is "Sign in with Keymail". Anyone without a Keymail
mailbox cannot sign up, cannot accept an invitation (invites now reach
plain email addresses, but accepting needs a session as that address),
and cannot finish `carlos auth login`. For Carloku, a general-use product,
that is the front door shut to almost everyone.

Success: a person with any email address can sign up and sign in to the
console with a link sent to that address; an owner can invite any email
address and the invitee can accept; `carlos auth login` and every
re-confirmed action work for that person; Keymail users type their
address into the same field and land in Keymail's own sign-in, as they
do today from the button.

## 2. Rulings (Paul, 2026-10-21)

- **R1. Re-confirmation for email users is a fresh email link.** Sensitive
  actions that today demand a fresh Keymail passkey gesture
  (`requireFreshAuth`) demand, for a session that signed in by email, a
  fresh link sent to the same address. Passkeys for email users are a
  later piece of work.
- **R2. Sign-up by email is open to anyone, rate-limited.** Same as
  Keymail today: the first successful link for a new address creates the
  user, and the existing first-landing path mints their personal account.
  Caps per address, per client IP and overall stop the form being a way to
  send mail to strangers.
- **R3. Console email comes from a carloku.com address on carloku.com.**
  `CARLOS_CONSOLE_MAIL_FROM=noreply@carloku.com` (builder's call on the
  local part; see §9). The platform default
  (`noreply@console-mail.bab.<suffix>`) stays the default for other
  deployments.
- **R4. Enable it.** Paul's instruction ("finish it out and enable it")
  is the security ruling the invite-delivery rollout was waiting on for
  the `ses:SendEmail` grant (`console_mail_from` / `console_mail_region`).
  Roll order stays dev first, then flagship.
- **R5. One door; Keymail is discovered, not chosen.** There is no
  separate "Sign in with Keymail" action. The person types their address;
  if it is a Keymail address (paul@keymail.dev) the console sends them
  straight into Keymail's OAuth ceremony, and otherwise it emails a link
  and code.

## 3. Goals, non-goals, anti-goals

Goals
- One email field on the console's front page. A Keymail address goes to
  Keymail's sign-in; any other address gets a magic link and code, which
  sign up a new person and sign in a returning one.
- A code in the same email, so a link opened on a phone can still finish
  a sign-in started on a laptop.
- Re-confirmation (step-up) by email link or code for email sessions.
- Invites a non-Keymail invitee can accept, with the invite page sending
  the invitee through sign-in and back.
- `carlos auth login` works end to end for an email user.
- Enabled and verified live on dev, then carloku.com.

Non-goals
- Passkeys / WebAuthn in the console (R1 defers it).
- CAPTCHA or bot scoring. Rate limits only.
- Linking two addresses into one person. The address is the identity, as
  today.
- Changing the Keymail OAuth flow or Keymail step-up. (The old
  Apps-inbox confirmation link is retired, §11.)
- A login hint to Keymail. Keymail has none; whoever authenticates there
  is who signs in, as today.
- Tito's console (blocked on the store boundary; it picks this up with
  its next release like any other deployment).

Anti-goals (a failure even if it works)
- **A downgrade.** A session that signed in with Keymail must never be
  able to re-confirm by email instead of its passkey. A stolen Keymail
  session gains nothing from this feature.
- **An oracle.** The front page must not reveal whether an address has an
  account here or was rate-limited. (Whether an address is a Keymail
  mailbox is already public through Keymail's own lookup, so being sent
  to Keymail reveals nothing new. Discovery must therefore use only that
  public answer, never console state such as a stored grant.)
- **A mail cannon.** Nobody can make the console send more than a handful
  of messages to an address they do not control.
- **Link scanners burning links.** Corporate mail scanners fetch every
  URL in a message. A GET must never consume a token.
- Mail to `@keymail.dev`-style addresses by SMTP. Keymail's MX refuses or
  screens unknown senders, so Keymail mailboxes are reached through
  Keymail, never through SES.

## 4. One door: discovery, then delivery

### 4.1 Discovery (R5)

`POST /auth/continue` takes `address` (validated per §11) and decides:

1. The domain is the configured Keymail host (`CONSOLE_KEYMAIL`'s host,
   e.g. keymail.dev): **Keymail**.
2. Otherwise ask Keymail's public federation lookup (new
   `Keymail.HasMailbox(ctx, addr) (bool, error)`, the same
   `/api/federation/lookup` call `SendFederated` makes, 5 s timeout):
   - 200: **Keymail** (a Keymail-served custom domain).
   - 400 "could not determine target mailbox", 404 "no such mailbox",
     409 "inbox not yet claimed" (checked live 2026-10-21): **email**.
   - timeout, network error, 5xx: **email** (a non-Keymail-host domain
     should not lose sign-in because Keymail is down).

Only public answers decide (anti-oracle): never a stored grant or any
other console state.

**Keymail** means exactly what `GET /auth/signin` does today (state and
PKCE cookie, redirect to `AuthorizeURL`), plus the `next` cookie of §8.
The session it makes is `method=keymail`. Whoever authenticates at
Keymail is who signs in, even if they typed a different address.

**Email** means a challenge (§5) and an emailed link and code (§4.2).

`GET /auth/signin` stays as the ceremony's entry (the redirect target,
and old bookmarks) but nothing on the page links to it any more.

### 4.2 How a sign-in or step-up message is sent

Only to addresses discovery called **email**, so only by the console
mailer (`ConsoleMailer.Send`, SES). Never by SES to a Keymail-host
address (anti-goal); discovery has already routed those to Keymail.

**No console mailer** (none configured, or not yet verified): the front
page says so plainly for an email-bound address ("this console can't
send email yet") instead of pretending a message is on its way. This
reveals only that the address is not a Keymail mailbox, which is public.
This answer is decided **before** any rate limit and is the same whether
or not the request was throttled, so throttling stays invisible (codex
r3-1). With a working mailer, a rate-limited request gets the normal
"check your email" page.

**Readiness** (codex r3-2). `ConsoleMailer` gains `Ready(ctx) bool`: the
existing private `ready` (verified answers cached for the process, a
not-verified answer rechecked after `consoleMailPendingRecheck`) behind a
short context (2 s; a timeout reads as not ready). `/auth/continue` asks
it before creating a challenge. A nil mailer is not ready.

**Sends run off the request.** The handler records the challenge, starts
the send on a background goroutine with its own bounded context (20 s),
and renders the "check your email" page at once, so send latency is no
oracle. A process-wide semaphore (builder's call on the size; 4 is fine)
bounds concurrent sends; a send that cannot get a slot within its
context is dropped and logged.

### 4.3 Invites and notices keep the two-channel sender

Invites (existing chain) and the notices of §11 go to addresses the
person did not just type, so they keep federation first, then SES, with
this fall-through rule: today `SendFederated` wraps every lookup error
as `ErrNoKeymailMailbox`. Only the definitive 400/404/409 answers mean
"no Keymail mailbox" and may fall through to SES. A timeout, network
error or 5xx stops when the domain is the Keymail host, and otherwise
falls through. `ErrFederationPriceTooHigh`, `IsKeymailBlocked`, and a
delivery failure after a successful lookup all stop: the address IS a
Keymail mailbox.

## 5. The challenge

A **challenge** is one emailed link + code, for one purpose, bound to the
browser that asked.

Table (new, in its own file with a `migrateEmailSignin()` per the
authstore/step-up precedent, and classified in `claimSQLTables`):

```
email_challenges(
  id_hash      TEXT PRIMARY KEY,  -- sha256 of the browser-held id
  link_hash    TEXT UNIQUE,       -- sha256 of the emailed link token
  code_mac     TEXT NOT NULL,     -- HMAC-SHA256(process key, id || code)
  purpose      TEXT NOT NULL,     -- 'signin' | 'stepup'
  address      TEXT NOT NULL,
  session_hash TEXT NOT NULL DEFAULT '',  -- stepup: the session it serves
  pending      TEXT NOT NULL DEFAULT '',  -- stepup: pending_actions token hash
  next         TEXT NOT NULL DEFAULT '',  -- signin: local path to land on
  attempts     INTEGER NOT NULL DEFAULT 0,
  created_at   INTEGER NOT NULL,
  expires_at   INTEGER NOT NULL
)
```

- **Lifetime 15 minutes** (`ConfirmationTTL`'s number). Single use: a
  successful link or code deletes the row in the same transaction that
  checks it (burn-then-check, the `TakeConfirmation` shape).
- **The browser holds the id** in an HttpOnly cookie
  (`carlos_console_email`, `Secure` on https, `SameSite=Lax`, 15 min).
  Only hashes are stored, so a database read is not a sign-in.
- **The code** is 6 digits from crypto/rand, stored as
  HMAC-SHA256 over (challenge id, code) under a **key held only in the
  console process's memory**, minted at start (codex r2-1). A plain hash
  would not do: six digits under an id the requester knows fall to an
  offline search in under a second, so a database read would be a
  sign-in. The cost is that a console restart invalidates codes in
  flight; links still work (they are 32 random bytes, hashed), and asking
  again sends a fresh code. **5 wrong attempts** burn
  the challenge. A code is only ever checked against the challenge named
  by this browser's cookie, so there is no global code space to guess in.
- **The link token** is 32 random bytes, base64url, the `randomToken`
  shape.
- **Guessing is bounded per address, not just per challenge** (codex 2).
  A fresh challenge must not buy a fresh guessing budget. Wrong codes are
  counted per address across all its challenges (10 per day) and per
  client IP (30 per hour). Past either, code checks for that address (or
  from that IP) are refused until the window passes, and only the
  challenge the wrong code was entered against is burned. Other
  challenges' **links** keep working: they carry 32 random bytes and an
  attacker without the mailbox cannot have them (codex r2-2). A rate-limited request (§7) creates **no** challenge at all,
  while rendering the same page, so refused requests are not redeemable
  either.
- One live challenge per (browser, purpose): asking again replaces the
  browser's previous one.

### 5.1 Sign-in by link

`GET /auth/email/link?t=<token>` never consumes anything. It looks the
token up and renders a confirm page ("Sign in as you@example.com", one
button) carrying the token in a POST form. Unknown or expired tokens get
the "link expired" message.

`POST /auth/email/link` (token in the body) consumes the challenge and
signs **this** browser in, whichever browser it is. That is what a magic
link means: possession of the mailbox. The confirm page names the
address, so a forwarded link shows whose account it opens.

**Login CSRF** (codex 1). The link token alone does not protect the POST:
an attacker holds a valid token for their own address and could
auto-submit it from their own page into a victim's browser. So:
- the GET sets a fresh random nonce in an HttpOnly `SameSite=Lax` cookie
  (`carlos_console_linkconfirm`, 15 min) and puts the same nonce in the
  form; the POST requires both and compares them in constant time. A
  cross-site POST carries no Lax cookie and fails;
- every pre-session POST in this feature (`/auth/continue`, the link POST,
  the code POSTs) also refuses a request whose `Origin` (or, when absent,
  `Sec-Fetch-Site`) names another site. Absent both, allow (old clients).
The code POST is already browser-bound through the challenge cookie,
which a cross-site POST does not carry.

### 5.2 Sign-in by code

The "check your email" page has a code box. `POST /auth/email/code`
checks the code against the challenge named by this browser's cookie and
signs this browser in. Wrong code: the page again, with an error, the
attempts counter up. This is the cross-device path.

### 5.3 What signing in does

Exactly what the Keymail callback does after it has an address, so both
doors share one function (`finishSignin(w, r, address, method, next)`):
`EnsureUser` (sign-up when `created`), `CreateSession`, session cookie,
welcome cookie when created, then `next` if set and safe, else
`redirectToCurrentAccount`. No grant token is stored for an email sign-in
(it has none), and an existing grant is left alone.

**A link or code sign-in also records a step-up** (`RecordStepUp` at the
moment of use). It is the same proof a step-up would ask for, minutes
later, of the same mailbox; without it a signed-out `carlos auth login`
sends two emails back to back.

### 5.4 The session remembers how it was proved

`sessions` gains `method TEXT NOT NULL DEFAULT 'keymail'`. Existing rows
read as `keymail`, which is true of every session today (the
Apps-inbox confirmation link is a Keymail channel and stays `keymail`).
A session made by §5.1 or §5.2 is `email`; a Keymail callback's is
`keymail`. Discovery sends Keymail addresses to Keymail, so a Keymail
user always re-confirms with their passkey. `session(r)` (or a sibling)
exposes the method.

## 6. Re-confirmation (R1)

`requireFreshAuth` branches on the session's method:

- **`keymail`**: unchanged. Keymail step-up, fail closed when Keymail
  cannot do it. No email path. (Anti-goal: no downgrade.)
- **`email`**: stash the pending action exactly as today
  (`StashPendingAction`), create a `stepup` challenge bound to this
  session hash and that pending token, send it by the console mailer (§4.2),
  and render a "check your email" page with a code box.
  - `GET /auth/email/link?t=` for a stepup challenge renders a confirm
    page; `POST` requires a session cookie whose hash equals the
    challenge's `session_hash` (and the nonce of §5.1). Opened in another browser it refuses
    with "open this link in the browser where you started, or type the
    code there". It never signs anyone in.
  - `POST /auth/email/stepup/code` checks the code for the stepup
    challenge named by this browser's cookie, and requires the same
    session.
  - On success: `RecordStepUp(raw, addr, now, now)` (the gesture time is
    the moment the link or code was used), then redeem the pending
    action, then `resume`, the same tail as `handleStepUpCallback`. The
    challenge stores the pending token's **hash**, and the existing
    `TakePendingAction` hashes its argument, so add a sibling
    `takePendingActionByHash` keeping the same transaction, session
    binding, expiry check and single-use delete (codex 3).
  - A step-up challenge lives **10 minutes**, the pending action's own
    lifetime (`stepUpFlowTTL`), not 15 (codex 6).

The five-minute `StepUpWindow` applies from that moment, as for Keymail.

The emailed text for a step-up names the action ("confirm: approve a
`carlos auth login` request", etc.) where the pending action's path maps
cleanly to a description; otherwise a generic "confirm an action in the
console". Builder's call on the mapping; it is copy, so it goes through
copy review.

## 7. Rate limits (R2)

In-memory sliding windows, the `inviteWindow` shape (one console process
per deployment). Refusals are invisible to the requester: the "check your
email" page renders as normal and the send is skipped and logged. The
numbers (builder may tune within reason):

| Key | Limit |
| --- | --- |
| per address, any purpose | 1 per 60 s, 5 per hour, 20 per day |
| per client IP (`s.clientIP`, edge-proof aware) | 10 emails per hour; 30 `/auth/continue` submissions per hour of any outcome (each one may cost a Keymail lookup) |
| whole console | 200 per hour |

Step-up challenges have their own per-address window (1 per 60 s,
10 per hour) and do not count against sign-in's address, IP or global
caps (the requester is already signed in as that address). Code attempts
are bounded per challenge (§5), not here.

Bounces and complaints: the console's sending identity is an ordinary
platform mail domain, so its configuration set and the account-level SES
suppression list apply. Nothing new here.

## 8. Where people come back to (`next`)

Signed-out visits to pages that need a session today redirect to `/` and
lose their place. Two of them matter for this feature:

- `GET /invite/{token}` signed out: render the front page with the
  invited address pre-filled ("Sign in as x@y to accept"), and `next` set
  to the invite page (carried as a hidden field into `/auth/continue`, so
  either discovery outcome gets it).
- `GET /auth/cli` signed out: `next=/auth/cli` (so a CLI user can sign in
  and land on the code entry).

**Onboarding runs before `next`** (codex 8). `finishSignin` first does
what `redirectToCurrentAccount` does to pick or mint the current account
(so a brand-new user always has their personal account), and only then
redirects to `next` instead of the account page.

`next` is accepted only as a local path: starts with `/`, not `//`, no
backslash, parsed by `url.Parse` with no scheme or host. For email it is
stored on the challenge row; for Keymail in a short cookie beside the
auth cookie (`carlos_console_next`, 10 min). Anything else is dropped
silently and the usual landing applies.

## 9. Mail on carloku.com (R3)

- `CARLOS_CONSOLE_MAIL_FROM=noreply@carloku.com` on the flagship console.
  `mail.EnsureDomain` creates the SES identity for `carloku.com` under the
  ops account (`bab`, referrer `console-mail`) and logs the DKIM records
  it needs. Publish them in the carloku.com Route53 zone (in
  carloku-infrastructure, where that zone is managed, unless the
  console's own DNS writer already owns it).
- Before relying on it, check that no tenant has a mail-domain claim on
  `carloku.com` (it is served by an app; a claim conflict refuses
  `EnsureDomain`) and that SES in the console's mail region is out of the
  SES sandbox. Checked 2026-10-21: SES in eu-west-1 is in production
  (50,000/day), no SES identity exists for carloku.com, and the
  carloku.com hosted zone is in the flagship account. If `carloku.com`
  turns out to be refused, use `noreply@mail.carloku.com` (builder's call,
  reported to Paul) rather than stop.
- The flagship console today has `CARLOS_MAIL_REGION=eu-west-1` and
  `CARLOS_MAIL_PLATFORM_SUFFIX=oncarlos.com`, and its mailer has already
  made and verified `console-mail.bab.oncarlos.com` (SES: DKIM SUCCESS,
  config set `carlos-bab`). So the host role can create identities; only
  the send grant is missing. That default identity is the fallback if
  carloku.com is held up.
- **An existing identity needs ownership proof** (codex 9). If an SES
  identity for carloku.com exists when the mailer first runs, and CARLOS
  did not make it, `EnsureDomain` refuses to adopt it unless TXT
  `_carlos-mail.carloku.com` = `carlos-mail-account=bab` is published.
  None existed on 2026-10-21; check again at rollout, and publish the TXT
  if one has appeared.
- carloku-infrastructure's region-host module call for the console's box
  sets `console_mail_from = "noreply@carloku.com"` and
  `console_mail_region` to the console's mail region, a targeted apply
  (whole-root applies restart every box).
- Dev: the platform default From, verified on the dev platform suffix.
- `CARLOS_HOST_CLAIM` still disables the console mailer (#250). If the
  flagship console runs claims-on by the time this rolls, that is a
  blocker to raise, not to work around.

Message text says "CARLOS platform console" today. Whether carloku.com's
messages say "Carloku" is a copy question for the copy review; if it
needs a deployment display name, that is a small config addition the
builder may make (`CARLOS_CONSOLE_NAME`, default "CARLOS platform").

## 10. Front page and copy

- Home: one email field and one button. No Keymail button (R5).
- "Check your email" page (replaces `check.html`'s Keymail-only text):
  where it was sent, the code box, "nothing arriving?" advice.
- The "this console can't send email yet" refusal (§4.2).
- Link confirm page, step-up "check your email" page, the three email
  bodies (sign-in, step-up, invite), the invite page's signed-out
  variant, the `team.html` invite copy (still says "the platform never
  emails anyone"), and the invite form's "keymail address" error.
- Every user-facing string goes through the copy-review skill before it
  lands in a file. No em dashes ([[copy voice]]).

## 11. Smaller changes that fall out

- `notifyCLIIssued` and `notifyStoreDecided` (storenotify.go, the
  object-storage approved/declined notice to the customer): when the
  address has no Keymail grant, send the notice through §4's sender
  (federation, then SES under the same fall-through rule), never
  straight to SES (codex 4). Operator alerts stay Keymail-only
  (operators are Keymail users).
- `handleContinue` (`POST /auth/continue`): becomes the form's target
  and runs discovery (§4.1). Its old body (an Apps-inbox confirmation
  link for addresses with a stored grant) goes. An empty address
  re-renders the front page with the bad-address error.
  It validates with the invite handler's rule (codex 7): lower-case,
  trim, `ValidAddress`, and `netmail.ParseAddress(addr).Address == addr`,
  so `a<victim@example.com>` and list forms are refused before any
  challenge, rate-limit key or user row exists. That one canonical
  string is used everywhere after.
- `handleConfirm` (`/auth/confirm?token=`, the old Apps-inbox link) is
  retired: nothing mints those links any more, and any already sent
  lapse within 15 minutes of the roll. The route stays (claim-routes) and
  renders the existing "link expired" message; `CreateConfirmation`'s
  last caller goes (leave the table; `PurgeExpired` keeps clearing it).
- The "Check your Keymail" template and `page.KeymailURL` go.
- `PurgeExpired` also clears expired `email_challenges`.
- New routes go into `testdata/claim-routes.txt`, the invariant fixtures
  and probes, like every route.

## 12. Tests that prove it

Through real wiring (the console fixture, the real store, the router),
with fakes only at the edges (fake Keymail, fake console mailer, a
controllable clock):

- Sign-up by link for a new address: user created, personal account
  minted, session `method=email`, welcome shown.
- Link GET does not consume; POST does; second POST fails.
- Code: right code signs in; 5 wrong codes burn; a code from another
  browser's challenge does not work.
- Discovery: Keymail-host address → redirect to Keymail with the state
  cookie; lookup 200 → Keymail; 400/404/409 → email; lookup timeout →
  email; a stored grant for a 404 address still → email (no console
  state in discovery).
- The "check your email" page is byte-identical for a new address, a
  known one, and a rate-limited one.
- Invite and notice chain: definitive no-mailbox → SES; lookup 5xx on a
  Keymail-host address → nothing sent; blocked → nothing sent.
- Rate limits at each key; a refused request creates no challenge.
- Guessing: wrong codes across several fresh challenges for one address
  stop at the per-address cap (codex 2).
- Login CSRF: a cross-site auto-submitted link POST without the nonce
  cookie is refused (codex 1).
- Signed-out `carlos auth login` by a new email user, without advancing
  the clock between sign-in and approval: one email, a personal account
  minted, CLI approved (codex 5, 8).
- Step-up: an email session doing a gated action gets an email challenge,
  the pending action resumes after the code or same-session link; the link
  in another browser refuses; a **keymail** session never gets an email
  challenge (downgrade test).
- `next`: invite page round trip for an email invitee from signed-out to
  member; unsafe `next` values ignored.
- `carlos auth login` approval by an email user end to end.
- Migration on an existing database: old sessions read `keymail`.

## 13. Rollout

1. Merge (CI green on a fresh main merge), release, roll dev boxes → dev
   console. Live check on dev: sign up with a plain email address, accept
   an invite, approve a CLI login with an emailed code.
2. Flagship: SES identity for carloku.com verified, region-host grant
   applied (targeted), env set on the console unit, roll flagship boxes →
   console. The same three live checks on carloku.com, plus a Keymail
   sign-in to prove nothing regressed.

## 14. Left to the builder

Names of files, functions and routes beyond those fixed above; the
semaphore size; the step-up action descriptions; tuning the rate numbers
within the same order of magnitude; template structure.

## 15. Review dispositions

Builder check (rev 1), settled as builder's calls:
- B1 superseded by R5: Keymail addresses never get an email link, so the
  sign-in door sets the session method (§4.1, §5.4).
- B2 email sign-in counts as a step-up (§5.3); step-up has its own
  per-address window (§7).
- B3 carloku.com refused: fall back to `noreply@mail.carloku.com` (§9).
- B4 federation fall-through rule (§4), from Keymail's live answers.
- B5 `notifyStoreDecided` joins `notifyCLIIssued` (§11).

Astra round 1 (`docs/superpowers/reviews/CODEX-FINDINGS-email-signin-r1.md`),
all accepted: codex 1 login CSRF (§5.1); codex 2 per-address guessing
budget (§5); codex 3 pending-action hash (§6); codex 4 no SMTP to Keymail
(§4, §11); codex 5 CLI step-up cooldown (§5.3, §7); codex 6 step-up TTL
(§6); codex 7 strict address parsing (§11); codex 8 onboarding before
`next` (§8); codex 9 existing-identity proof (§9).

Astra round 2 (`CODEX-FINDINGS-email-signin-r2.md`): all nine round-1
fixes confirmed. Both new findings accepted: r2-1 codes MACed under an
in-memory key (§5); r2-2 the guessing cap throttles codes but never burns
another browser's link (§5).

Rev 4 (Paul, 2026-10-21, R5): no "Sign in with Keymail" action; discovery
from the address picks Keymail OAuth or email. Consequences: sign-in and
step-up messages go by SES only (§4.2); the Apps-inbox confirmation link
is retired (§11); B1's channel-based method is gone; invites and notices
keep the B4 two-channel rule (§4.3).

Astra round 3 (`CODEX-FINDINGS-email-signin-r3.md`): all eleven earlier
fixes confirmed, no P1. Both P2s accepted: r3-1 the no-mailer answer is
decided before rate limits (§4.2); r3-2 `ConsoleMailer.Ready` (§4.2).

Rev 6 (build, 2026-10-21, after the whole-branch Astra review): the
browser no longer holds a per-challenge id. A sign-in **flow** cookie
(`carlos_console_flow`), set when the sign-in form renders, ties a
browser's sign-in challenges together; the code box uses the flow's
newest live challenge; `/auth/continue` serialises per flow. A refused
request (rate limits) sends nothing and leaves the flow's live challenge
untouched, or, if it has none, records a never-sent challenge so a wrong
code reads the same either way. That replaces §5's "a rate-limited
request creates no challenge": no refused request creates a challenge
the mailbox could redeem. Over the per-IP submit cap there is no Keymail
lookup and no send. Step-up challenges are found by session. Reasons:
the per-challenge id leaked throttling through the code box and lost the
code on a double-clicked Continue.
