carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Console email sign-in (magic links), with Keymail found from the address
2
3Status: rev 6, 2026-10-21 (approved rev 5 + the build's sign-in-flow change, §15). Follows
4[console invite delivery](2026-09-28-console-invite-delivery-design.md),
5whose §4 console mailer this reuses and whose line 4 named this spec.
6
7## 1. Why
8
9The console's only door is "Sign in with Keymail". Anyone without a Keymail
10mailbox cannot sign up, cannot accept an invitation (invites now reach
11plain email addresses, but accepting needs a session as that address),
12and cannot finish `carlos auth login`. For Carloku, a general-use product,
13that is the front door shut to almost everyone.
14
15Success: a person with any email address can sign up and sign in to the
16console with a link sent to that address; an owner can invite any email
17address and the invitee can accept; `carlos auth login` and every
18re-confirmed action work for that person; Keymail users type their
19address into the same field and land in Keymail's own sign-in, as they
20do today from the button.
21
22## 2. Rulings (Paul, 2026-10-21)
23
24- **R1. Re-confirmation for email users is a fresh email link.** Sensitive
25 actions that today demand a fresh Keymail passkey gesture
26 (`requireFreshAuth`) demand, for a session that signed in by email, a
27 fresh link sent to the same address. Passkeys for email users are a
28 later piece of work.
29- **R2. Sign-up by email is open to anyone, rate-limited.** Same as
30 Keymail today: the first successful link for a new address creates the
31 user, and the existing first-landing path mints their personal account.
32 Caps per address, per client IP and overall stop the form being a way to
33 send mail to strangers.
34- **R3. Console email comes from a carloku.com address on carloku.com.**
35 `CARLOS_CONSOLE_MAIL_FROM=noreply@carloku.com` (builder's call on the
36 local part; see §9). The platform default
37 (`noreply@console-mail.bab.<suffix>`) stays the default for other
38 deployments.
39- **R4. Enable it.** Paul's instruction ("finish it out and enable it")
40 is the security ruling the invite-delivery rollout was waiting on for
41 the `ses:SendEmail` grant (`console_mail_from` / `console_mail_region`).
42 Roll order stays dev first, then flagship.
43- **R5. One door; Keymail is discovered, not chosen.** There is no
44 separate "Sign in with Keymail" action. The person types their address;
45 if it is a Keymail address (paul@keymail.dev) the console sends them
46 straight into Keymail's OAuth ceremony, and otherwise it emails a link
47 and code.
48
49## 3. Goals, non-goals, anti-goals
50
51Goals
52- One email field on the console's front page. A Keymail address goes to
53 Keymail's sign-in; any other address gets a magic link and code, which
54 sign up a new person and sign in a returning one.
55- A code in the same email, so a link opened on a phone can still finish
56 a sign-in started on a laptop.
57- Re-confirmation (step-up) by email link or code for email sessions.
58- Invites a non-Keymail invitee can accept, with the invite page sending
59 the invitee through sign-in and back.
60- `carlos auth login` works end to end for an email user.
61- Enabled and verified live on dev, then carloku.com.
62
63Non-goals
64- Passkeys / WebAuthn in the console (R1 defers it).
65- CAPTCHA or bot scoring. Rate limits only.
66- Linking two addresses into one person. The address is the identity, as
67 today.
68- Changing the Keymail OAuth flow or Keymail step-up. (The old
69 Apps-inbox confirmation link is retired, §11.)
70- A login hint to Keymail. Keymail has none; whoever authenticates there
71 is who signs in, as today.
72- Tito's console (blocked on the store boundary; it picks this up with
73 its next release like any other deployment).
74
75Anti-goals (a failure even if it works)
76- **A downgrade.** A session that signed in with Keymail must never be
77 able to re-confirm by email instead of its passkey. A stolen Keymail
78 session gains nothing from this feature.
79- **An oracle.** The front page must not reveal whether an address has an
80 account here or was rate-limited. (Whether an address is a Keymail
81 mailbox is already public through Keymail's own lookup, so being sent
82 to Keymail reveals nothing new. Discovery must therefore use only that
83 public answer, never console state such as a stored grant.)
84- **A mail cannon.** Nobody can make the console send more than a handful
85 of messages to an address they do not control.
86- **Link scanners burning links.** Corporate mail scanners fetch every
87 URL in a message. A GET must never consume a token.
88- Mail to `@keymail.dev`-style addresses by SMTP. Keymail's MX refuses or
89 screens unknown senders, so Keymail mailboxes are reached through
90 Keymail, never through SES.
91
92## 4. One door: discovery, then delivery
93
94### 4.1 Discovery (R5)
95
96`POST /auth/continue` takes `address` (validated per §11) and decides:
97
981. The domain is the configured Keymail host (`CONSOLE_KEYMAIL`'s host,
99 e.g. keymail.dev): **Keymail**.
1002. Otherwise ask Keymail's public federation lookup (new
101 `Keymail.HasMailbox(ctx, addr) (bool, error)`, the same
102 `/api/federation/lookup` call `SendFederated` makes, 5 s timeout):
103 - 200: **Keymail** (a Keymail-served custom domain).
104 - 400 "could not determine target mailbox", 404 "no such mailbox",
105 409 "inbox not yet claimed" (checked live 2026-10-21): **email**.
106 - timeout, network error, 5xx: **email** (a non-Keymail-host domain
107 should not lose sign-in because Keymail is down).
108
109Only public answers decide (anti-oracle): never a stored grant or any
110other console state.
111
112**Keymail** means exactly what `GET /auth/signin` does today (state and
113PKCE cookie, redirect to `AuthorizeURL`), plus the `next` cookie of §8.
114The session it makes is `method=keymail`. Whoever authenticates at
115Keymail is who signs in, even if they typed a different address.
116
117**Email** means a challenge (§5) and an emailed link and code (§4.2).
118
119`GET /auth/signin` stays as the ceremony's entry (the redirect target,
120and old bookmarks) but nothing on the page links to it any more.
121
122### 4.2 How a sign-in or step-up message is sent
123
124Only to addresses discovery called **email**, so only by the console
125mailer (`ConsoleMailer.Send`, SES). Never by SES to a Keymail-host
126address (anti-goal); discovery has already routed those to Keymail.
127
128**No console mailer** (none configured, or not yet verified): the front
129page says so plainly for an email-bound address ("this console can't
130send email yet") instead of pretending a message is on its way. This
131reveals only that the address is not a Keymail mailbox, which is public.
132This answer is decided **before** any rate limit and is the same whether
133or not the request was throttled, so throttling stays invisible (codex
134r3-1). With a working mailer, a rate-limited request gets the normal
135"check your email" page.
136
137**Readiness** (codex r3-2). `ConsoleMailer` gains `Ready(ctx) bool`: the
138existing private `ready` (verified answers cached for the process, a
139not-verified answer rechecked after `consoleMailPendingRecheck`) behind a
140short context (2 s; a timeout reads as not ready). `/auth/continue` asks
141it before creating a challenge. A nil mailer is not ready.
142
143**Sends run off the request.** The handler records the challenge, starts
144the send on a background goroutine with its own bounded context (20 s),
145and renders the "check your email" page at once, so send latency is no
146oracle. A process-wide semaphore (builder's call on the size; 4 is fine)
147bounds concurrent sends; a send that cannot get a slot within its
148context is dropped and logged.
149
150### 4.3 Invites and notices keep the two-channel sender
151
152Invites (existing chain) and the notices of §11 go to addresses the
153person did not just type, so they keep federation first, then SES, with
154this fall-through rule: today `SendFederated` wraps every lookup error
155as `ErrNoKeymailMailbox`. Only the definitive 400/404/409 answers mean
156"no Keymail mailbox" and may fall through to SES. A timeout, network
157error or 5xx stops when the domain is the Keymail host, and otherwise
158falls through. `ErrFederationPriceTooHigh`, `IsKeymailBlocked`, and a
159delivery failure after a successful lookup all stop: the address IS a
160Keymail mailbox.
161
162## 5. The challenge
163
164A **challenge** is one emailed link + code, for one purpose, bound to the
165browser that asked.
166
167Table (new, in its own file with a `migrateEmailSignin()` per the
168authstore/step-up precedent, and classified in `claimSQLTables`):
169
170```
171email_challenges(
172 id_hash TEXT PRIMARY KEY, -- sha256 of the browser-held id
173 link_hash TEXT UNIQUE, -- sha256 of the emailed link token
174 code_mac TEXT NOT NULL, -- HMAC-SHA256(process key, id || code)
175 purpose TEXT NOT NULL, -- 'signin' | 'stepup'
176 address TEXT NOT NULL,
177 session_hash TEXT NOT NULL DEFAULT '', -- stepup: the session it serves
178 pending TEXT NOT NULL DEFAULT '', -- stepup: pending_actions token hash
179 next TEXT NOT NULL DEFAULT '', -- signin: local path to land on
180 attempts INTEGER NOT NULL DEFAULT 0,
181 created_at INTEGER NOT NULL,
182 expires_at INTEGER NOT NULL
183)
184```
185
186- **Lifetime 15 minutes** (`ConfirmationTTL`'s number). Single use: a
187 successful link or code deletes the row in the same transaction that
188 checks it (burn-then-check, the `TakeConfirmation` shape).
189- **The browser holds the id** in an HttpOnly cookie
190 (`carlos_console_email`, `Secure` on https, `SameSite=Lax`, 15 min).
191 Only hashes are stored, so a database read is not a sign-in.
192- **The code** is 6 digits from crypto/rand, stored as
193 HMAC-SHA256 over (challenge id, code) under a **key held only in the
194 console process's memory**, minted at start (codex r2-1). A plain hash
195 would not do: six digits under an id the requester knows fall to an
196 offline search in under a second, so a database read would be a
197 sign-in. The cost is that a console restart invalidates codes in
198 flight; links still work (they are 32 random bytes, hashed), and asking
199 again sends a fresh code. **5 wrong attempts** burn
200 the challenge. A code is only ever checked against the challenge named
201 by this browser's cookie, so there is no global code space to guess in.
202- **The link token** is 32 random bytes, base64url, the `randomToken`
203 shape.
204- **Guessing is bounded per address, not just per challenge** (codex 2).
205 A fresh challenge must not buy a fresh guessing budget. Wrong codes are
206 counted per address across all its challenges (10 per day) and per
207 client IP (30 per hour). Past either, code checks for that address (or
208 from that IP) are refused until the window passes, and only the
209 challenge the wrong code was entered against is burned. Other
210 challenges' **links** keep working: they carry 32 random bytes and an
211 attacker without the mailbox cannot have them (codex r2-2). A rate-limited request (§7) creates **no** challenge at all,
212 while rendering the same page, so refused requests are not redeemable
213 either.
214- One live challenge per (browser, purpose): asking again replaces the
215 browser's previous one.
216
217### 5.1 Sign-in by link
218
219`GET /auth/email/link?t=<token>` never consumes anything. It looks the
220token up and renders a confirm page ("Sign in as you@example.com", one
221button) carrying the token in a POST form. Unknown or expired tokens get
222the "link expired" message.
223
224`POST /auth/email/link` (token in the body) consumes the challenge and
225signs **this** browser in, whichever browser it is. That is what a magic
226link means: possession of the mailbox. The confirm page names the
227address, so a forwarded link shows whose account it opens.
228
229**Login CSRF** (codex 1). The link token alone does not protect the POST:
230an attacker holds a valid token for their own address and could
231auto-submit it from their own page into a victim's browser. So:
232- the GET sets a fresh random nonce in an HttpOnly `SameSite=Lax` cookie
233 (`carlos_console_linkconfirm`, 15 min) and puts the same nonce in the
234 form; the POST requires both and compares them in constant time. A
235 cross-site POST carries no Lax cookie and fails;
236- every pre-session POST in this feature (`/auth/continue`, the link POST,
237 the code POSTs) also refuses a request whose `Origin` (or, when absent,
238 `Sec-Fetch-Site`) names another site. Absent both, allow (old clients).
239The code POST is already browser-bound through the challenge cookie,
240which a cross-site POST does not carry.
241
242### 5.2 Sign-in by code
243
244The "check your email" page has a code box. `POST /auth/email/code`
245checks the code against the challenge named by this browser's cookie and
246signs this browser in. Wrong code: the page again, with an error, the
247attempts counter up. This is the cross-device path.
248
249### 5.3 What signing in does
250
251Exactly what the Keymail callback does after it has an address, so both
252doors share one function (`finishSignin(w, r, address, method, next)`):
253`EnsureUser` (sign-up when `created`), `CreateSession`, session cookie,
254welcome cookie when created, then `next` if set and safe, else
255`redirectToCurrentAccount`. No grant token is stored for an email sign-in
256(it has none), and an existing grant is left alone.
257
258**A link or code sign-in also records a step-up** (`RecordStepUp` at the
259moment of use). It is the same proof a step-up would ask for, minutes
260later, of the same mailbox; without it a signed-out `carlos auth login`
261sends two emails back to back.
262
263### 5.4 The session remembers how it was proved
264
265`sessions` gains `method TEXT NOT NULL DEFAULT 'keymail'`. Existing rows
266read as `keymail`, which is true of every session today (the
267Apps-inbox confirmation link is a Keymail channel and stays `keymail`).
268A session made by §5.1 or §5.2 is `email`; a Keymail callback's is
269`keymail`. Discovery sends Keymail addresses to Keymail, so a Keymail
270user always re-confirms with their passkey. `session(r)` (or a sibling)
271exposes the method.
272
273## 6. Re-confirmation (R1)
274
275`requireFreshAuth` branches on the session's method:
276
277- **`keymail`**: unchanged. Keymail step-up, fail closed when Keymail
278 cannot do it. No email path. (Anti-goal: no downgrade.)
279- **`email`**: stash the pending action exactly as today
280 (`StashPendingAction`), create a `stepup` challenge bound to this
281 session hash and that pending token, send it by the console mailer (§4.2),
282 and render a "check your email" page with a code box.
283 - `GET /auth/email/link?t=` for a stepup challenge renders a confirm
284 page; `POST` requires a session cookie whose hash equals the
285 challenge's `session_hash` (and the nonce of §5.1). Opened in another browser it refuses
286 with "open this link in the browser where you started, or type the
287 code there". It never signs anyone in.
288 - `POST /auth/email/stepup/code` checks the code for the stepup
289 challenge named by this browser's cookie, and requires the same
290 session.
291 - On success: `RecordStepUp(raw, addr, now, now)` (the gesture time is
292 the moment the link or code was used), then redeem the pending
293 action, then `resume`, the same tail as `handleStepUpCallback`. The
294 challenge stores the pending token's **hash**, and the existing
295 `TakePendingAction` hashes its argument, so add a sibling
296 `takePendingActionByHash` keeping the same transaction, session
297 binding, expiry check and single-use delete (codex 3).
298 - A step-up challenge lives **10 minutes**, the pending action's own
299 lifetime (`stepUpFlowTTL`), not 15 (codex 6).
300
301The five-minute `StepUpWindow` applies from that moment, as for Keymail.
302
303The emailed text for a step-up names the action ("confirm: approve a
304`carlos auth login` request", etc.) where the pending action's path maps
305cleanly to a description; otherwise a generic "confirm an action in the
306console". Builder's call on the mapping; it is copy, so it goes through
307copy review.
308
309## 7. Rate limits (R2)
310
311In-memory sliding windows, the `inviteWindow` shape (one console process
312per deployment). Refusals are invisible to the requester: the "check your
313email" page renders as normal and the send is skipped and logged. The
314numbers (builder may tune within reason):
315
316| Key | Limit |
317| --- | --- |
318| per address, any purpose | 1 per 60 s, 5 per hour, 20 per day |
319| 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) |
320| whole console | 200 per hour |
321
322Step-up challenges have their own per-address window (1 per 60 s,
32310 per hour) and do not count against sign-in's address, IP or global
324caps (the requester is already signed in as that address). Code attempts
325are bounded per challenge (§5), not here.
326
327Bounces and complaints: the console's sending identity is an ordinary
328platform mail domain, so its configuration set and the account-level SES
329suppression list apply. Nothing new here.
330
331## 8. Where people come back to (`next`)
332
333Signed-out visits to pages that need a session today redirect to `/` and
334lose their place. Two of them matter for this feature:
335
336- `GET /invite/{token}` signed out: render the front page with the
337 invited address pre-filled ("Sign in as x@y to accept"), and `next` set
338 to the invite page (carried as a hidden field into `/auth/continue`, so
339 either discovery outcome gets it).
340- `GET /auth/cli` signed out: `next=/auth/cli` (so a CLI user can sign in
341 and land on the code entry).
342
343**Onboarding runs before `next`** (codex 8). `finishSignin` first does
344what `redirectToCurrentAccount` does to pick or mint the current account
345(so a brand-new user always has their personal account), and only then
346redirects to `next` instead of the account page.
347
348`next` is accepted only as a local path: starts with `/`, not `//`, no
349backslash, parsed by `url.Parse` with no scheme or host. For email it is
350stored on the challenge row; for Keymail in a short cookie beside the
351auth cookie (`carlos_console_next`, 10 min). Anything else is dropped
352silently and the usual landing applies.
353
354## 9. Mail on carloku.com (R3)
355
356- `CARLOS_CONSOLE_MAIL_FROM=noreply@carloku.com` on the flagship console.
357 `mail.EnsureDomain` creates the SES identity for `carloku.com` under the
358 ops account (`bab`, referrer `console-mail`) and logs the DKIM records
359 it needs. Publish them in the carloku.com Route53 zone (in
360 carloku-infrastructure, where that zone is managed, unless the
361 console's own DNS writer already owns it).
362- Before relying on it, check that no tenant has a mail-domain claim on
363 `carloku.com` (it is served by an app; a claim conflict refuses
364 `EnsureDomain`) and that SES in the console's mail region is out of the
365 SES sandbox. Checked 2026-10-21: SES in eu-west-1 is in production
366 (50,000/day), no SES identity exists for carloku.com, and the
367 carloku.com hosted zone is in the flagship account. If `carloku.com`
368 turns out to be refused, use `noreply@mail.carloku.com` (builder's call,
369 reported to Paul) rather than stop.
370- The flagship console today has `CARLOS_MAIL_REGION=eu-west-1` and
371 `CARLOS_MAIL_PLATFORM_SUFFIX=oncarlos.com`, and its mailer has already
372 made and verified `console-mail.bab.oncarlos.com` (SES: DKIM SUCCESS,
373 config set `carlos-bab`). So the host role can create identities; only
374 the send grant is missing. That default identity is the fallback if
375 carloku.com is held up.
376- **An existing identity needs ownership proof** (codex 9). If an SES
377 identity for carloku.com exists when the mailer first runs, and CARLOS
378 did not make it, `EnsureDomain` refuses to adopt it unless TXT
379 `_carlos-mail.carloku.com` = `carlos-mail-account=bab` is published.
380 None existed on 2026-10-21; check again at rollout, and publish the TXT
381 if one has appeared.
382- carloku-infrastructure's region-host module call for the console's box
383 sets `console_mail_from = "noreply@carloku.com"` and
384 `console_mail_region` to the console's mail region, a targeted apply
385 (whole-root applies restart every box).
386- Dev: the platform default From, verified on the dev platform suffix.
387- `CARLOS_HOST_CLAIM` still disables the console mailer (#250). If the
388 flagship console runs claims-on by the time this rolls, that is a
389 blocker to raise, not to work around.
390
391Message text says "CARLOS platform console" today. Whether carloku.com's
392messages say "Carloku" is a copy question for the copy review; if it
393needs a deployment display name, that is a small config addition the
394builder may make (`CARLOS_CONSOLE_NAME`, default "CARLOS platform").
395
396## 10. Front page and copy
397
398- Home: one email field and one button. No Keymail button (R5).
399- "Check your email" page (replaces `check.html`'s Keymail-only text):
400 where it was sent, the code box, "nothing arriving?" advice.
401- The "this console can't send email yet" refusal (§4.2).
402- Link confirm page, step-up "check your email" page, the three email
403 bodies (sign-in, step-up, invite), the invite page's signed-out
404 variant, the `team.html` invite copy (still says "the platform never
405 emails anyone"), and the invite form's "keymail address" error.
406- Every user-facing string goes through the copy-review skill before it
407 lands in a file. No em dashes ([[copy voice]]).
408
409## 11. Smaller changes that fall out
410
411- `notifyCLIIssued` and `notifyStoreDecided` (storenotify.go, the
412 object-storage approved/declined notice to the customer): when the
413 address has no Keymail grant, send the notice through §4's sender
414 (federation, then SES under the same fall-through rule), never
415 straight to SES (codex 4). Operator alerts stay Keymail-only
416 (operators are Keymail users).
417- `handleContinue` (`POST /auth/continue`): becomes the form's target
418 and runs discovery (§4.1). Its old body (an Apps-inbox confirmation
419 link for addresses with a stored grant) goes. An empty address
420 re-renders the front page with the bad-address error.
421 It validates with the invite handler's rule (codex 7): lower-case,
422 trim, `ValidAddress`, and `netmail.ParseAddress(addr).Address == addr`,
423 so `a<victim@example.com>` and list forms are refused before any
424 challenge, rate-limit key or user row exists. That one canonical
425 string is used everywhere after.
426- `handleConfirm` (`/auth/confirm?token=`, the old Apps-inbox link) is
427 retired: nothing mints those links any more, and any already sent
428 lapse within 15 minutes of the roll. The route stays (claim-routes) and
429 renders the existing "link expired" message; `CreateConfirmation`'s
430 last caller goes (leave the table; `PurgeExpired` keeps clearing it).
431- The "Check your Keymail" template and `page.KeymailURL` go.
432- `PurgeExpired` also clears expired `email_challenges`.
433- New routes go into `testdata/claim-routes.txt`, the invariant fixtures
434 and probes, like every route.
435
436## 12. Tests that prove it
437
438Through real wiring (the console fixture, the real store, the router),
439with fakes only at the edges (fake Keymail, fake console mailer, a
440controllable clock):
441
442- Sign-up by link for a new address: user created, personal account
443 minted, session `method=email`, welcome shown.
444- Link GET does not consume; POST does; second POST fails.
445- Code: right code signs in; 5 wrong codes burn; a code from another
446 browser's challenge does not work.
447- Discovery: Keymail-host address → redirect to Keymail with the state
448 cookie; lookup 200 → Keymail; 400/404/409 → email; lookup timeout →
449 email; a stored grant for a 404 address still → email (no console
450 state in discovery).
451- The "check your email" page is byte-identical for a new address, a
452 known one, and a rate-limited one.
453- Invite and notice chain: definitive no-mailbox → SES; lookup 5xx on a
454 Keymail-host address → nothing sent; blocked → nothing sent.
455- Rate limits at each key; a refused request creates no challenge.
456- Guessing: wrong codes across several fresh challenges for one address
457 stop at the per-address cap (codex 2).
458- Login CSRF: a cross-site auto-submitted link POST without the nonce
459 cookie is refused (codex 1).
460- Signed-out `carlos auth login` by a new email user, without advancing
461 the clock between sign-in and approval: one email, a personal account
462 minted, CLI approved (codex 5, 8).
463- Step-up: an email session doing a gated action gets an email challenge,
464 the pending action resumes after the code or same-session link; the link
465 in another browser refuses; a **keymail** session never gets an email
466 challenge (downgrade test).
467- `next`: invite page round trip for an email invitee from signed-out to
468 member; unsafe `next` values ignored.
469- `carlos auth login` approval by an email user end to end.
470- Migration on an existing database: old sessions read `keymail`.
471
472## 13. Rollout
473
4741. Merge (CI green on a fresh main merge), release, roll dev boxes → dev
475 console. Live check on dev: sign up with a plain email address, accept
476 an invite, approve a CLI login with an emailed code.
4772. Flagship: SES identity for carloku.com verified, region-host grant
478 applied (targeted), env set on the console unit, roll flagship boxes →
479 console. The same three live checks on carloku.com, plus a Keymail
480 sign-in to prove nothing regressed.
481
482## 14. Left to the builder
483
484Names of files, functions and routes beyond those fixed above; the
485semaphore size; the step-up action descriptions; tuning the rate numbers
486within the same order of magnitude; template structure.
487
488## 15. Review dispositions
489
490Builder check (rev 1), settled as builder's calls:
491- B1 superseded by R5: Keymail addresses never get an email link, so the
492 sign-in door sets the session method (§4.1, §5.4).
493- B2 email sign-in counts as a step-up (§5.3); step-up has its own
494 per-address window (§7).
495- B3 carloku.com refused: fall back to `noreply@mail.carloku.com` (§9).
496- B4 federation fall-through rule (§4), from Keymail's live answers.
497- B5 `notifyStoreDecided` joins `notifyCLIIssued` (§11).
498
499Astra round 1 (`docs/superpowers/reviews/CODEX-FINDINGS-email-signin-r1.md`),
500all accepted: codex 1 login CSRF (§5.1); codex 2 per-address guessing
501budget (§5); codex 3 pending-action hash (§6); codex 4 no SMTP to Keymail
502(§4, §11); codex 5 CLI step-up cooldown (§5.3, §7); codex 6 step-up TTL
503(§6); codex 7 strict address parsing (§11); codex 8 onboarding before
504`next` (§8); codex 9 existing-identity proof (§9).
505
506Astra round 2 (`CODEX-FINDINGS-email-signin-r2.md`): all nine round-1
507fixes confirmed. Both new findings accepted: r2-1 codes MACed under an
508in-memory key (§5); r2-2 the guessing cap throttles codes but never burns
509another browser's link (§5).
510
511Rev 4 (Paul, 2026-10-21, R5): no "Sign in with Keymail" action; discovery
512from the address picks Keymail OAuth or email. Consequences: sign-in and
513step-up messages go by SES only (§4.2); the Apps-inbox confirmation link
514is retired (§11); B1's channel-based method is gone; invites and notices
515keep the B4 two-channel rule (§4.3).
516
517Astra round 3 (`CODEX-FINDINGS-email-signin-r3.md`): all eleven earlier
518fixes confirmed, no P1. Both P2s accepted: r3-1 the no-mailer answer is
519decided before rate limits (§4.2); r3-2 `ConsoleMailer.Ready` (§4.2).
520
521Rev 6 (build, 2026-10-21, after the whole-branch Astra review): the
522browser no longer holds a per-challenge id. A sign-in **flow** cookie
523(`carlos_console_flow`), set when the sign-in form renders, ties a
524browser's sign-in challenges together; the code box uses the flow's
525newest live challenge; `/auth/continue` serialises per flow. A refused
526request (rate limits) sends nothing and leaves the flow's live challenge
527untouched, or, if it has none, records a never-sent challenge so a wrong
528code reads the same either way. That replaces §5's "a rate-limited
529request creates no challenge": no refused request creates a challenge
530the mailbox could redeem. Over the per-IP submit cap there is no Keymail
531lookup and no send. Step-up challenges are found by session. Reasons:
532the per-challenge id leaked throttling through the code box and lost the
533code on a double-clicked Continue.
534