Field Notes · Trust & Safety

Trust Is Additive

Once a user holds a legitimate starter grant, how do you keep their usage healthy without punishing real users? We add up a few facts we already know about an account (a verified email, push on, account age, a past purchase), so the limits land hardest on brand-new accounts and barely touch proven ones.

Nervous EnergyJune 3, 20262 min story · 6 with the deep dive

Protecting the grant at signup only solves half the problem. We wrote about that half in how we recognize a returning identity, so the same person can't claim a once per fresh Gmail address. But even a legitimately held grant can still be used in ways the grant wasn't for. You can hammer the API at machine speed, or run the same prompt fifty times trying to brute-force one result. None of that is , but left unchecked it distorts the same signup metrics in roughly the same way, and the people you most want to keep are the ones who feel a clumsy fix first.

We saw the pattern clearly enough in the logs: a 45-day-old account iterating earnestly on an image they cared about, getting slowed down by a threshold that was set for account-hour-old speedrunners. The problem isn't that limits exist. It's that a single threshold treats everyone the same, and the same threshold that barely slows an abuse pattern can genuinely frustrate a real user. The users most likely to feel a clumsy fix are exactly the real ones you want to keep.

The fix we landed on isn't one threshold. It's a small for each account, built additively from cheap signals we already have: whether they've verified their email, whether they've granted push notifications, how old the account is, whether they've ever paid us anything. No model required. The total becomes headroom in two ways. First, how many concurrent jobs that account can run at once. Second, how the scales against them when they submit work quickly. A brand-new unverified account sits near the floor on both. A verified user with notifications on who's bought a token pack sits near the ceiling, without ever having thought about it.

That arrangement does what a single threshold can't. A two-hour-old account submitting the same prompt at machine speed hits a meaningful wall. A month-old paying member iterating on one image barely notices the system exists. And because the score is built from signals we already collect, the cost to compute it is essentially zero. A new account can raise its own score by doing things we'd want real users to do anyway: verify email, enable push. When they hit a limit, the error message tells them exactly which knobs would help. If they act on the nudge, the next block doesn't happen. If they don't, we've at least learned something.

The same score shapes a second lever, the velocity cooldown that kicks in when an account submits work too fast. New, unproven accounts get throttled harder and sooner; established ones barely feel it. The deep dive below works through how that cooldown is computed, how it catches the same prompt submitted fifty times in a row, and the separate failure-rate limit we needed for subscribers, whose refilling token pool leaves them insensitive to cost but not to time.

The trust score that decides headroom is one of the things Trust Is a Budget reads when deciding who to extend more rope to. The two posts are sister pieces: this one builds the trust, that one spends it.

Trust is additive. You don't need a model, you just need to start counting.

The deep dive
That's the story. Below is how we actually built it, in detail, for anyone who wants to implement something similar. If that's not you, this is a clean place to stop.

Concurrent-job throttling, tuned to trust

The trust score pulls from four signals:

  • Whether they've granted push notifications.
  • Whether they've verified their email.
  • How old the account is, with meaningful bumps at 7 days and 30 days.
  • Whether they've ever paid us anything, either a one-time token-pack purchase or an active subscription. This carries by far the biggest bump, because willingness to spend real money is the strongest signal we get short of watching behavior over time.

The score gets clamped to a sensible range and becomes the for that account. When someone hits the limit, the error message names what would help: "You have 2 generations running at once. Please wait for one to finish. Verify your email or enable notifications to unlock more." That nudge does double duty: it tells the user what knobs exist, and it points them toward exactly the signals that would raise their score.

Velocity-based cooldown, with a look at repetition

The cooldown formula is three scalars: a threshold rate, a seconds-per-excess multiplier, and a cap. Below the threshold, no wait. Above it, the wait grows proportionally to how far over you are. An earlier version used a tiered JSON config; collapsing it into three scalar knobs eliminated a whole class of "the JSON parsed but the boundaries don't quite match what we meant" bugs and made it easy to reason about what any given change actually did.

the cooldown, in three numbers
if (rate <= threshold) {
  wait = 0;                                  // under the line, no cooldown
} else {
  wait = (rate - threshold) * secondsPerExcess;
  wait = Math.min(wait, cap);                // never longer than the cap
}

// threshold       = 3 generations per minute
// secondsPerExcess = 30   (each generation over the line)
// cap              = a configurable maximum wait

The current threshold is three generations per minute, deliberately generous. It came from a real : a 45-day-old account getting cooldowned at 1.8 submissions per minute during earnest iteration on one image.

The cooldown also accounts for two additional signals:

  • We hash each prompt after stripping stop words and sorting the remaining tokens, so "a red cat sitting on a couch" and "cat red couch sitting" land on the same hash. We keep the last ten hashes per user and apply a multiplier when repetition is high. Creative exploration doesn't look like that. Brute-forcing a single image does.
  • Account age. Brand-new accounts get an extra multiplier that decays as the account gets older. Most of the risk lives in the first few hours, and slowing those down costs almost nothing with real users, because real users spend time looking at results before submitting the next one.

A concrete example that shows up from time to time: a two-hour-old account submitting the same prompt at five times per minute gets a base cooldown of roughly a minute (two generations over the threshold at thirty seconds per excess), tripled by the repetition multiplier, doubled by the new-account multiplier, for a final wait of six minutes. Long enough to meaningfully slow the pattern, short enough that a real user genuinely stuck on one idea isn't locked out of their own app.

When those two aren't enough: a failure-rate cooldown

The two systems above handle most free-tier users comfortably, but they left a blind spot around subscribers. Subscribers bypass the velocity cooldown, because people who've paid have earned the right to push hard. The trouble was a small number using their effectively unlimited token pool to hammer the moderation pipeline, burning compute on prompts that kept failing. Our existing counter to abusive failures is a small per rejected generation, which works fine for free-tier users and one-time purchasers. It doesn't bite subscribers, because their pool refills monthly.

The fix is a third dial, a . For each user we track the ratio of recent generations that failed moderation over a rolling 24-hour window. Free-tier users see this as an additional multiplier on any velocity wait they were already going to get. Subscribers, who normally bypass velocity entirely, start seeing a flat cooldown once their daily failure ratio crosses a configurable threshold. One-time purchasers still bypass the whole thing, because the restocking fee handles them.

The architectural shift is worth naming. The first two systems apply economic disincentives: hit your cap and you wait, keep trying and you lose tokens. The failure-rate layer applies a temporal one: your ability to participate pauses for a while, regardless of whether you can afford to keep trying. We needed both, because the population insensitive to cost is still sensitive to time.

One detail worth mentioning, because a lot of teams will have a similar story. This feature existed as a knob in our admin control panel for months before we wired up the server side. The admin UI was writing to config keys the server didn't read, and nobody was watching for a behavior change that was never going to happen, so the drift went undetected. Having a knob in your tooling is not the same as having a feature in production. The only reliable check is observing whether your config changes produce different behavior in the places where behavior shows up: logs, metrics, Slack. That instinct, watching before you trust a control, is most of how we roll these things out.

A control panel instead of a code deploy

Making every threshold tunable through config was the right call. But raw config values in the Firebase console are a hostile editing experience, which means in practice only engineers touch them. So we built a small internal admin app: labels, tooltips, sensible defaults, type-aware inputs. Anyone on the team can see the current settings, understand what each does, and adjust without digging through config keys.

The control panel also includes a simulator. Plug in a hypothetical user profile (account age, trust signals, recent velocity, prompt repetition) and see what the current settings would do. It's one thing to look at a tier table and think "yeah, that seems right." It's a different thing to realize that a user on day three with no notifications and a 60% repetition rate ends up at a twelve-minute cooldown, and to decide whether that matches your intuition. The cost of building this tooling was low; the value of iterating on numbers without a code deploy has been higher than we expected.

Why these factors, and not others

We picked the factors we did because we already had good data on them, and each was cheap enough to evaluate inline, since scoring an account is just a few additions on values we already store. There's a version of this system that runs a scoring model over a dozen signals, but we didn't see a reason to build it until we had evidence the simple version wasn't working. So far it has been.

When we consider a new signal, the question is whether it could be gamed in a way that hurts real users. Email verification can't, because verifying email is already something we want real users to do. Same with push notifications. Account age is unfalsifiable. Purchase history is ground truth. We briefly had a penalty for users who disabled auto-save, thinking people who turn it off are often avoiding a trail. We dropped it once it became clear we were penalizing a non-trivial number of legitimate users who simply preferred a tidy gallery. We'd guessed at a motive instead of measuring one.

Beyond that, we watch the distribution of trust scores over time and we watch the block rate. If a signal starts producing a lot of blocks for users we recognize as real, that's the cue to revisit its weight or drop it.

What we'd take with us

  • Prefer formulas with a few scalar knobs to lookup tables with many entries. Scalar formulas are easier to reason about, expose cleaner defaults, and avoid a whole class of "the JSON parsed but the boundaries don't match what we meant" bugs.
  • Economic and temporal disincentives reach different populations. Users who don't feel price still feel time, so a system built on only one kind of pressure leaves the other half of the problem unaddressed.
  • Build the knobs into a tool a human can actually reach. Making every threshold configurable is a good start. Making those configurations tunable through an interface with a simulator attached turns from an on-call-engineer problem into a whole-team capability.
  • A knob in your admin panel is not the same as a feature in production. The only reliable check is watching whether config changes produce different behavior in logs and metrics, not just in the UI. The Diffusitron failure-rate cooldown sat dormant for months before we caught the drift.