Skip to content

Make transactional mail fail safely, and keep data until the researcher is told - #213

Merged
jodeleeuw merged 3 commits into
testfrom
mail-quota-breaker-and-retry
Aug 29, 2026
Merged

Make transactional mail fail safely, and keep data until the researcher is told#213
jodeleeuw merged 3 commits into
testfrom
mail-quota-breaker-and-retry

Conversation

@jodeleeuw

@jodeleeuw jodeleeuw commented Aug 29, 2026

Copy link
Copy Markdown
Member

Three commits, one failure story. Mail that could not be delivered, and research data deleted while the researcher still had not been told. Each section below is one commit, in order.

Verification codes and upload-failure notifications are not the same kind of mail, and the difference only shows up at the daily quota.

Verification code Upload-failure notification
Nature Realtime. Someone is watching a form; it expires in 24h. Deferrable. Still true an hour later.
Delivered by The request itself, synchronously onmailcreated, then the sweeper
Retried? Never Yes
On failure 503, vague message, cooldown cleared Stays queued, swept later

Until now both were queued and neither was retried, which produced the worst of both.

Two live bugs this fixes

Verification lied to the researcher. The endpoint returned 200 before delivery was attempted:

await verificationRef(uid).set({ ..., sentAt: now });  // cooldown armed
await sendMail({ ... });                                // queues; sends nothing
res.status(200).json({ success: true });                // ← before any send

So during a quota outage a researcher was told to check an inbox nothing was coming to, and given a 60-second cooldown before they could ask again.

Upload-failure notifications were silently skipped. A retryable ERROR was retried by nobody — onDocumentCreated doesn't re-fire on updates. Meanwhile upload-failure-notify.ts arms uploadFailure.notifiedAt in the same transaction that enqueues the mail, and lastNotifiedAt is a 24-hour floor across episodes. So the researcher was never told their data stopped arriving, nothing re-notified, and the experiment document positively asserted they had been told.

The breaker — mail-availability.ts, systemStatus/mail

Resend returns x-resend-daily-quota (quota used today) on successful responses, not just 429s. That's what makes this proactive: we learn we're at 94/100 while sending still works, instead of by failing.

Verification stops at a ceiling of 90; upload-failure notifications keep going to the full 100. That asymmetry is the design decision — a researcher waiting on a code can come back later, but a notification that data has stopped arriving is the only signal they get, so it should have the last sends of the day.

Two things I checked rather than assumed:

  • The rate-limit headers don't help. ratelimit-reset / retry-after describe the per-second window (10 req/s), not the daily quota.
  • Resend documents no daily reset time, so nothing depends on knowing one. unavailableUntil is next UTC midnight as a ceiling; what actually reopens sending is the sweeper landing a success. The deferrable path probes, the realtime path only reads. If the real reset is later, the sweeper re-arms the breaker; if earlier, it finds out first.

The header is free-plan-only, so it vanishes on a paid plan. Absent reads as "no daily cap" — this logic turns itself off on upgrade rather than needing removal.

Verification is now synchronous

The breaker is checked before a code is minted, a record written, or a cooldown armed — so clicking repeatedly during an outage costs zero Resend requests. The send is then awaited, and on failure the verification record is deleted so the researcher can retry immediately rather than waiting out a cooldown for a code that doesn't exist.

onMailCreated stands down for inline mail. The claim machinery would make the race safe, but the loser gets skipped-in-flight and can't report an outcome — which is exactly the ambiguity the inline path exists to remove.

Inline failures are terminal, never retryable: nothing will retry them, and a retryable error never gets delivery.expireAt, so calling them retryable would park an address outside the TTL's reach indefinitely.

We can't send verification codes right now. Please try again in a little while.

Vague on purpose, and the same for quota, an unverified domain, and a revoked key — none of it is actionable by a researcher. The code-entry form isn't opened, because there's no code to enter.

The sweeper — scheduled-mail-retry.ts, every 10 minutes

Re-drives retryable mail, and refuses three things:

  1. Inline mail, ever. A code delivered an hour late may already have expired.
  2. Ambiguous errors past 24h. Send transactional mail through Resend, not SES #211 made timeouts retryable only on the strength of Resend's Idempotency-Key, and Resend honours it for a day. REFUSED_ERRORS (refused, or never reached Resend) carry no duplicate risk and sweep at any age; everything else only inside the window.
  3. Anything while the breaker is shut. Sweeping into an exhausted quota fails every document and spends one of its three MAX_ATTEMPTS doing it — so a day-long outage would exhaust every queued mail's budget and turn them all terminal, the exact opposite of the point.

It also ages out mail nobody delivered in three days, marking it terminal so delivery.expireAt is finally written. That closes the hole runbook §4 flags: a retryable ERROR otherwise holds a researcher's address forever, outside the TTL, with nothing to catch it.

Alerting: documented, not built (runbook §6)

You cannot email yourself that you are out of email — same account, same quota. It has to be Cloud Monitoring delivering it. Two log-based metrics, no code, since mail-delivery.ts already logs everything at error level: daily_quota_exceeded (reactive) and the quota reading above ~80 (the leading indicator, and the useful one). Routed to a channel that isn't on the jspsych.org sending domain.

I left these as instructions rather than creating them, since alert policies are live changes to your GCP projects — happy to run them if you want.

Testing

Full suite: 81 suites, 1152 tests, plus tsc and lint.

33 new: 21 pure (both decision predicates as tables — availability, staleness, the UTC-midnight arithmetic, and the sweep predicate's safety argument) and 12 emulator-backed (breaker writes, inline terminality, and every sweep refusal).

The emulator suite uses its own status document. systemStatus/mail is a singleton, and half the assertions need the breaker shut — a suite that shut the real one would fail every other suite that sends mail, including contact-email-verify-emulator.test.js driving the real endpoint over HTTP, differently on each run under --maxWorkers=2. That's why _setMailStatusDocForTests exists in production code.

One thing worth knowing: the inline path had to get its own FUNCTIONS_EMULATOR gate. Without it, inline delivery bypassed the never-send guarantee onMailCreated makes, and four existing verification tests started failing on a send that was never going to happen — which is the gate earning its keep, in the direction it was designed for.

Also

Composite index for the sweep query (delivery.state, delivery.retryable), and a firestore.rules note that systemStatus stays unmatched and therefore default-denied — the account page renders from the 503, not from reading DataPipe's operational posture.

🤖 Generated with Claude Code


Second commit: stop deleting data the researcher was never told about

Follow-up in the same PR, because it's the other half of the same failure: the breaker and sweeper keep the notification alive, and this keeps the data it is about alive long enough to matter.

Two ways cleanup lost data

scheduled-upload-retry.ts deleted on createdAt <= now - 7d with no check on status and no check on whether anyone had been told. There's no GCS lifecycle rule on the bucket (I checked), so that sweep is the entire retention policy.

  1. A storage-provider outage. The entry is still pending with retries left — it would have uploaded fine on day eight. Deleting it on day seven throws away data that was never actually lost.
  2. A notification that never arrived. The seven days count from submission, so part of the window is spent before anything goes wrong. If the notification died on a quota outage, the window closes without the researcher ever learning there was one.

upload-retention.ts

Condition Outcome
Older than 14 days from createdAt delete — the ceiling wins over everything
pending with retries left retain — the upload may yet succeed
retainUntil in the future retain — the researcher has not been told
otherwise delete — unchanged

Its own module and a pure function: it decides the least reversible thing in the codebase, and scheduled-upload-retry.ts pulls in the whole provider stack, so a test wanting just this predicate would have to load all of it.

retainUntil is written by the sweeper while a notification is undelivered, covering every unresolved entry for the experimentdatapipe.queueDocId records only the entry that tripped the episode, so extending that alone would leave the rest of the episode's files expiring on schedule.

The retention pass runs before the breaker check, so it works while sending is paused. Extending is a Firestore write costing no quota, and a quota outage is exactly when data ages toward deletion behind a notification that never arrived — putting it after the check would disable the protection in the only case that needs it.

No contact email → no extension, and it falls out rather than being special-cased. upload-failure-notify.ts records suppressedReason: "no-contact-email" and returns before enqueuing, so no mail document exists for the sweeper to find. Plain seven days, which is right when there's nobody to tell.

The 14-day ceiling is load-bearing. Without it, an experiment whose provider is dead and whose owner never reads their mail would hold research payloads forever, silently.

The warning banner — unverified, not missing

ContactEmailGate already walls off every admin route until a usable address exists, so "no contact email" is nearly extinct. What the gate doesn't check is whether the address works: hasContactEmail() tests format only, never contactEmailVerified.

That's the gap. A typo passes the gate. So does anything the 2026-08 backfill seeded from Firebase Auth. And upload-failure-notify.ts mails the address regardless of verified status — so it bounces and is marked terminally failed somewhere nobody looks. The symptom of a notification you can't receive is silence, which is indistinguishable from everything being fine.

UnverifiedEmailBanner renders on the dashboard when contactEmailVerified is false and an address exists. Not dismissible, no flag to maintain — contactEmailVerified is written server-side by verify-contact-email.ts and only there, so it removes itself the moment it's acted on.

Testing

81 suites, 1162 tests (10 new), plus tsc and lint.

Runbook §7 and §8 document the retention rules and the banner.


Third commit: close the ways mail could stop for a month, or stall forever

The first two commits assume the only way sending fails is a daily quota that resets at midnight. That assumption is wrong in three directions, and each one turns a recoverable outage into permanent loss.

Why sending stopped decides how long it stays stopped

The free plan has two caps — 100/day and 3,000/month — on different clocks. Reusing the daily reset for a monthly exhaustion is not a rounding error:

Hit the monthly cap on the 20th → the breaker reopens at midnight → the sweeper probes into a cap with eleven days left to run → fails → spends one of each queued document's MAX_ATTEMPTS doing it. Three nights of that and every queued notification is terminal — the exact retry-budget exhaustion the breaker exists to prevent.

PauseKind Reset Why
daily-quota Next UTC midnight Unchanged
monthly-quota Start of next UTC month Different clock, up to 30× longer
systemic now + 15 min Nothing is exhausted; no reset time exists

pauseKindFor() is the table that maps a Resend error name onto one, and both call sites go through it rather than deciding for themselves.

Pinning dailyQuotaUsed at the limit now happens only for the daily cap. A monthly exhaustion says nothing about today's counter, and pinning it there would block verification on the reserve rule for a reason that has nothing to do with the reserve — and go on doing so after the daily counter reset.

Systemic failures shut it too, briefly

A revoked key or an unverified domain is not exhaustion, and nothing shut on it. So verificationAvailability answered "available" forever while every click minted a code, wrote a document, spent a real Resend request and failed — with no server-side ceiling on how often. Tripping the breaker bounds that to one request per 15 minutes however many researchers are pressing the button.

validation_error is deliberately not in SYSTEMIC_ERRORS. Resend uses it both for "your sending domain is unverified" (systemic) and "this recipient address is malformed" (one researcher's typo). One typo must not switch verification off for everybody, so the ambiguous name stays out and the unambiguous ones carry the rule.

The header reading is a guess, and it is now bounded

Worth being explicit about, because it is load-bearing and it is not documented. Resend documents x-resend-daily-quota's existence, not its semantics — and "used today" and "remaining today" are the same shape.

If it is really the plan limit, every reading is 100, the reserve rule is true forever, and verification is off for good — and because each send rewrites dailyQuotaObservedAt, the staleness escape hatch never fires either. Three things bound that now:

  1. usableQuotaReading() refuses anything outside [0, 100] at the write and ignores it at the read, logging loudly. That catches a header that turns out to be the monthly counter, or a remaining-quota value on a paid plan.
  2. Refusing a verification on the reserve alone logs at ERROR with a stable token, so the condition is alertable rather than silent.
  3. Runbook §5 says how to capture a real response header and check the number against the Resend dashboard. Do that before trusting it.

The verification record is the rate limit, so it stays

The first commit deleted it on a failed send, reasoning that a researcher whose code never arrived shouldn't wait out a cooldown for a code that doesn't exist. Right about the researcher, wrong about the endpoint:

sentAt is the only server-side throttle on that path, and deleting it removed the throttle from exactly the case that needs one. A failed send still costs a Resend request and still writes a mail document, so a signed-in researcher holding the button — or a loop on one valid ID token — drove both without any bound at all.

The record now survives with codeHash cleared: a cooldown, not a secret nobody received. verify-contact-email.ts answers "request a new one" rather than spending one of the five attempts on a code that was never sent. Two things make that safe rather than merely throttled — the systemic breaker above, and clearing the code itself.

Every document the sweep finds leaves by a door

The queries are unordered limit()s, so any document the pass can look at without changing, it will look at again next pass, forever — occupying the budget a deliverable notification needed. The decision table has no permanent "skip" left in it: every outcome either sends, or writes a terminal state that takes the document out of both queries. The only skips left are documents another invocation is actively holding, which resolve within LEASE_MS.

That matters beyond starvation. A retryable ERROR never gets delivery.expireAt, so it sits outside the TTL policy holding a researcher's address indefinitely. Ageing one out is how that address is finally deleted.

A second query finds stranded claims. deliverMailDocument's claim rewrites the document to PROCESSING with retryable: null before the send — so an instance that dies mid-send left a document that neither the retryable-ERROR query nor the TTL could see. A preempted instance or a mid-deploy roll stranded an address permanently. claimDecision already knew how to recover an expired lease; nothing ever asked it to.

The idempotency window now measures from startTime, which is when the key was first used and never moves. Measuring from the last attempt slid the window forward with every retry — so a document retried at +20h and again at +40h was sent on a key that expired at +24h, and Resend treated it as a new message. Exactly the double-send the window exists to prevent.

Retention moves next to the predicate that reads it

extendRetentionForExperiment lives in upload-retention.ts now. Splitting "may this data be destroyed, and what holds it back" across the mail sweeper, that module and the deletion sweep meant three files in two subsystems had to be read together to answer one question — and the mail subsystem had to know the uploadQueue's schema. The sweeper now says only what it actually knows ("this experiment's researcher has not been told"), and retention decides what that means.

Inverting it completely was considered and rejected. Asking "is a notification still undelivered?" at deletion time is the tidier shape, but it makes the least reversible operation in the codebase depend on a query that can fail — and a failing query there deletes data. retainUntil fails the other way: a write that doesn't happen costs an extension, not the data.

Two changes to the rule itself:

  • It covers documents about to be aged out, not just sent. That's the more important half — giving up is the case where the researcher will never be told at all, and it must not also be the moment their data quietly goes back on the original clock.
  • It skips a rewrite when the stored value is already more than half the grace window out. The sweeper runs every ten minutes for as long as a notification is undelivered, so without that floor a day-long outage with a couple of dozen queued notifications rewrites the same field tens of thousands of times to the same effect — against a 20,000/day free tier.

The deletion sweep pages past what it may not delete

cleanupOldEntries found entries by age, ascending, under one limit(50). But whether one may be destroyed is a separate question, and a retained entry is not removed from the result set by being looked at. Fifty retained entries at the head of the queue meant a pass that deleted nothing, forever: one experiment stuck behind a dead storage provider could stop every other experiment's payloads from being deleted, until the blockers crossed the 14-day ceiling up to a week later.

It now scans up to 500 with a cursor to find its 50. Same deletion budget, same retention rule — it just no longer mistakes a blocked head for an empty queue.

Also

  • mailCollection(), so a suite can isolate the collection the sweep queries. Isolating the status document was never enough on its own — the collection is the other shared singleton, and a suite that swept would deliver another suite's fixtures through its own transport. The trigger still binds to MAIL_COLLECTION, which is deploy-time configuration a test may not move.
  • One users/{uid} subscription on /admin, passed down, replacing two live listeners on the same document with their own loading flickers.
  • leaseIsExpired, pauseKindFor and unresolvedQueueEntriesQuery each now own a rule that was written out twice.
  • Age-out and retention writes run concurrently. A backlog was up to fifty serialized round-trips. Not batched — a batch is atomic, and one document purged mid-sweep would take the rest with it.
  • Composite index for delivery.state + delivery.leaseExpiresAt.

Testing

81 suites, 1190 tests — 1158 passing, plus tsc and lint clean. 28 new tests over the second commit, covering the monthly and systemic pause kinds, the bounded header reading, stranded-claim recovery, the age-out terminal write, the retention rewrite floor, and the paging deletion sweep.

32 failures in 5 suites, all pre-existing and unrelated: collision-cache, providers-zenodo-oauth, resolve-token-gdrive, oauth-connect-refresh-emulator, connect-static-token-emulator. I confirmed these by checking the same suites out at the previous commit and running them there — identical failures, and none of the five touches any module this commit changes. Worth a look on their own, but not from this branch.

Runbook §5 and §6 gain the breaker's three reasons, the "check what that number means" procedure, and the note that the deletion sweep pages.

jodeleeuw and others added 3 commits August 29, 2026 12:31
Verification codes and upload-failure notifications are not the same kind of
mail, and the difference only shows up at the daily quota. A code is
realtime -- someone is watching a form, and it expires in 24 hours -- so a
code delivered tomorrow is a failure with extra steps. "Your data stopped
arriving" is just as true an hour later.

Until now both were queued and neither was retried, which produced the worst
of both. The verification endpoint returned 200 before delivery was even
attempted, so a researcher was told to check an inbox nothing was coming to,
and was given a 60-second cooldown on asking again. And a queued
upload-failure mail that died on quota was retried by nobody, while
upload-failure-notify.ts had already armed the episode as "told them" in the
same transaction that enqueued it -- so the researcher was never notified and
the experiment document asserted that they had been.

THE BREAKER (mail-availability.ts, systemStatus/mail)

Resend returns x-resend-daily-quota -- the quota USED today -- on SUCCESSFUL
responses, not only on 429s. So the breaker is proactive: we learn we are at
94/100 while sending still works, rather than by failing. Verification stops
at a ceiling of 90; upload-failure notifications keep going to the full 100.
That asymmetry is the design decision. A researcher waiting on a code can
come back later; a notification that data has stopped arriving is the only
signal they get, so it should have the last sends of the day.

The rate-limit headers are no help here -- ratelimit-reset describes the
per-second window -- and Resend documents no daily reset time, so nothing
depends on knowing one. unavailableUntil is next UTC midnight as a CEILING;
what actually reopens sending is the sweeper landing a success. The
deferrable path probes, the realtime path only reads.

The header is free-plan-only, so it vanishes on a paid plan. Absent reads as
"no daily cap", which means this logic turns itself off on upgrade rather
than needing removal.

VERIFICATION IS NOW SYNCHRONOUS

The breaker is checked before a code is minted, a record is written, or a
cooldown is armed -- so a researcher clicking during an outage costs zero
Resend requests. The send is then awaited, and a failure clears the
verification record so they can retry immediately instead of waiting out a
cooldown for a code that does not exist. onMailCreated stands down for
inline mail: the claim would make the race safe, but the loser gets
skipped-in-flight and could not report an outcome, which is the ambiguity
the inline path exists to remove.

Inline failures are terminal, never retryable. Nothing will retry them, and
a retryable error is never given delivery.expireAt -- so calling them
retryable would park an address outside the TTL's reach indefinitely.

The message is vague on purpose, and the same for every cause: quota, an
unverified domain, a revoked key. None of it is actionable by a researcher.

THE SWEEPER (scheduled-mail-retry.ts, every 10 minutes)

Re-drives retryable mail, and refuses three things. Inline mail, ever.
Ambiguous errors past 24 hours, because mail-delivery.ts only made timeouts
retryable on the strength of Resend's Idempotency-Key and Resend honours it
for a day -- REFUSED_ERRORS (refused, or never reached Resend) carry no such
risk and sweep at any age. And anything at all while the breaker is shut,
because sweeping into an exhausted quota fails every document AND spends one
of its three MAX_ATTEMPTS doing it, so a day-long outage would exhaust every
queued mail's budget and turn them all terminal.

It also ages out mail nobody could deliver in three days, marking it
terminal so delivery.expireAt is finally written. That closes the hole
runbook 4 notes: a retryable ERROR otherwise holds an address forever,
outside the TTL policy, with no sweeper to catch it.

Alerting is documented rather than built (runbook 6): two log-based metrics,
no code, routed somewhere Google delivers -- you cannot email yourself that
you are out of email.

Adds a composite index for the sweep query, a firestore.rules note that
systemStatus stays unmatched and therefore closed, and 33 tests: 21 pure
(both predicates as tables) and 12 emulator-backed. The emulator suite uses
its own status document, because systemStatus/mail is a singleton and a
suite that shut the real breaker would fail every other suite that sends
mail, differently each run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1dNU7EnmBBMTn8MWfojDL
Two ways the queue cleanup lost data nobody meant to lose. It deleted on
`createdAt <= now - 7d` with no check on status and no check on whether
anyone had been told -- and since there is no GCS lifecycle rule on the
bucket, that sweep is the entire retention policy.

  1. A STORAGE PROVIDER OUTAGE. The entry is still pending with retries left,
     so it would have uploaded fine on day eight. Deleting it on day seven
     throws away data that was never actually lost.

  2. A NOTIFICATION THAT NEVER ARRIVED. The seven days count from SUBMISSION,
     so part of the window is spent before anything goes wrong -- and if the
     notification died on a quota outage, the window closes without the
     researcher ever learning there was one.

upload-retention.ts now decides, per entry: delete past 14 days from
createdAt whatever else is true; retain while pending with retries left;
retain while retainUntil is in the future; otherwise delete, unchanged.

Its own module and a pure function, because it decides the least reversible
thing here and scheduled-upload-retry.ts pulls in the whole provider stack --
a test wanting only this predicate would have to load all of it.

scheduled-mail-retry.ts writes retainUntil while a notification is
undelivered, covering EVERY unresolved entry for the experiment rather than
the one that tripped the episode. datapipe.queueDocId records only the
trigger; extending that alone would leave the rest of the episode's files
expiring on schedule, which is the same bug in miniature.

The retention pass runs BEFORE the breaker check and therefore also while
sending is paused. Extending is a Firestore write that costs no quota, and a
quota outage is precisely when data ages towards deletion behind a
notification that never arrived -- putting it after the check would disable
the protection in the only case that needs it.

An experiment whose owner has no contact email is never extended, and that
falls out rather than being special-cased: upload-failure-notify.ts records
suppressedReason "no-contact-email" and returns before enqueuing, so no mail
document exists for the sweeper to find. Plain seven days, which is right
when there is nobody to tell.

The 14-day ceiling is load-bearing. Without it an experiment whose provider
is dead and whose owner never reads their mail would hold research payloads
forever, silently, at DataPipe's cost.

THE WARNING BANNER

Targets UNVERIFIED addresses, not missing ones. ContactEmailGate already
walls off every admin route until a usable address exists, so "no contact
email" is nearly extinct; what the gate does not check is whether the address
works, since hasContactEmail() tests format only. A typo passes it, so does
anything the 2026-08 backfill seeded from Auth, and upload-failure-notify.ts
mails the address regardless of verified status -- so it bounces and is
marked terminally failed somewhere nobody looks.

Not dismissible and no flag to maintain: contactEmailVerified is written
server-side by verify-contact-email.ts and only there, so the banner removes
itself the moment it is acted on.

10 new tests, 1162 total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1dNU7EnmBBMTn8MWfojDL
The breaker and the sweeper both assumed the only way sending fails is a
daily quota that resets at midnight. Neither assumption survives contact
with the other failure modes.

WHY SENDING STOPPED DECIDES HOW LONG IT STAYS STOPPED

The free plan has two caps -- 100/day and 3,000/month -- on different
clocks. Reusing the daily reset for a monthly exhaustion is not a small
error: hit the monthly cap on the 20th and the breaker reopens at midnight,
the sweeper probes into a cap with eleven days left to run, and spends one
of each queued document's MAX_ATTEMPTS doing it. Three nights of that and
every queued notification is terminal -- the exact retry-budget exhaustion
the breaker exists to prevent.

`PauseKind` names the three reasons and `pauseUntil` maps each to a reset:
daily-quota to the next UTC midnight, monthly-quota to the next month, and
systemic to a 15-minute cooldown. `pauseKindFor` is the table that turns a
Resend error name into one, and both call sites go through it.

SYSTEMIC FAILURES TRIP IT TOO

A revoked key or an unverified domain is not exhaustion, and previously
nothing shut on it: verificationAvailability answered "available" forever
while every click minted a code, wrote a document, spent a real request and
failed, with no server-side ceiling. Tripping bounds that to one request per
SYSTEMIC_PAUSE_MS however many researchers are pressing the button.

`validation_error` is deliberately NOT in SYSTEMIC_ERRORS. Resend uses it
both for an unverified sending domain and for one researcher's typo, and a
typo must not switch verification off for everybody.

THE HEADER READING IS A GUESS, AND IT IS BOUNDED

Resend documents x-resend-daily-quota's existence, not its semantics --
"used today" and "remaining today" are the same shape. If it is really the
plan limit, every reading is 100, the reserve rule is true forever, and each
send rewrites dailyQuotaObservedAt so the staleness escape hatch never fires
either. `usableQuotaReading` refuses anything outside [0, 100] at the write
and ignores it at the read, refusing loudly; refusing a verification on the
reserve alone now logs at ERROR with a stable token, so the condition is
alertable rather than silent. The runbook says how to check the number
against the dashboard before trusting it.

THE VERIFICATION RECORD IS THE RATE LIMIT, SO IT STAYS

The failure branch used to delete it, reasoning that a researcher whose code
never arrived should not wait out a cooldown for a code that does not exist.
Right about the researcher, wrong about the endpoint: `sentAt` is the only
server-side throttle on that path, and deleting it removed the throttle from
exactly the case that needs one. The record now survives with `codeHash`
cleared -- a cooldown, not a secret nobody received -- and
verify-contact-email.ts answers "request a new one" rather than spending one
of the five attempts on a code that was never sent.

EVERY DOCUMENT THE SWEEP FINDS LEAVES BY A DOOR

The queries are unordered limits, so anything the pass can look at without
changing it will look at again next pass, forever, occupying the budget a
deliverable notification needed. The decision table has no permanent skip
left in it: every outcome either sends or writes a terminal state that takes
the document out of both queries. The only skips are documents another
invocation holds, which resolve within LEASE_MS.

That matters beyond starvation. A retryable ERROR never gets
delivery.expireAt, so it sits outside the TTL policy holding a researcher's
address indefinitely. Ageing one out is how that address is finally deleted.

A second query finds stranded claims. deliverMailDocument's claim rewrites
the document to PROCESSING with `retryable: null` BEFORE the send, so an
instance that dies mid-send left a document neither the retryable-ERROR
query nor the TTL could see -- a preempted instance or a mid-deploy roll
stranded an address permanently. claimDecision already knew how to recover
an expired lease; nothing ever asked it to.

The idempotency window is now measured from `startTime`, which is when the
key was first used and never moves. Measuring from the last attempt slid the
window forward with every retry, so a document retried at +20h and again at
+40h was sent on a key that expired at +24h and Resend treated it as a new
message.

RETENTION MOVES NEXT TO THE PREDICATE THAT READS IT

`extendRetentionForExperiment` lives in upload-retention.ts now. Splitting
"may this data be destroyed, and what holds it back" across the mail
sweeper, that module and the deletion sweep meant three files in two
subsystems had to be read together to answer one question, and the mail
subsystem had to know the uploadQueue's schema. The sweeper now says only
what it knows -- this researcher has not been told -- and retention decides
what that means.

Inverting it completely was considered and rejected: asking "is a
notification still undelivered?" at deletion time makes the least reversible
operation in the codebase depend on a query that can fail, and a failing
query there deletes data. `retainUntil` fails the other way.

It covers documents about to be AGED OUT as well as sent, which is the more
important half -- giving up is when the researcher will never be told at
all, and it must not also be when their data goes back on the original
clock. Once per experiment rather than once per document, and skipped
entirely when the stored value is already more than half the grace window
out: the sweeper runs every ten minutes, so without that floor a day-long
outage rewrites the same field tens of thousands of times to no effect,
against a 20,000/day free tier.

THE DELETION SWEEP PAGES PAST WHAT IT MAY NOT DELETE

cleanupOldEntries found entries by age, ascending, with one limit(50) -- but
whether one may be destroyed is a separate question, and a retained entry is
not removed from the result set by being looked at. Fifty retained entries
at the head meant a pass that deleted nothing, forever: one experiment stuck
behind a dead provider stopped every other experiment's payloads from being
deleted until the blockers crossed the 14-day ceiling. It now scans up to
500 with a cursor to find its 50.

ALSO

- mailCollection() indirection, so a suite can isolate the collection the
  sweep queries. Isolating the status document was never enough on its own;
  the collection is the other shared singleton. The trigger still binds to
  MAIL_COLLECTION, which is deploy-time configuration a test may not move.
- One users/{uid} subscription on /admin, passed down, replacing two.
- leaseIsExpired, pauseKindFor and unresolvedQueueEntriesQuery each own a
  rule that was previously written out twice.
- Age-out and retention writes run concurrently; a backlog was up to fifty
  serialized round-trips. Not batched -- a batch is atomic, and one document
  purged mid-sweep would take the rest with it.
- Composite index for delivery.state + delivery.leaseExpiresAt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1dNU7EnmBBMTn8MWfojDL
@jodeleeuw jodeleeuw changed the title Treat realtime and deferrable mail differently when quota runs out Make transactional mail fail safely, and keep data until the researcher is told Aug 29, 2026
@jodeleeuw
jodeleeuw merged commit efb07b4 into test Aug 29, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant