--- title: "ADR 0006 — How per-link rate limiting is counted, and what happens when the counter breaks" type: decision tags: [signpost, adr, decision] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:38:11.296Z" --- # ADR 0006 — How per-link rate limiting is counted, and what happens when the counter breaks **Status** Accepted · **Date** 2026-08-09 · **Supersedes** nothing · **Relates to** PRD §5.2 (Quick Connect), §10 NF9 (`AGENTS.md` p95 < 200 ms), §11 S3 (per-token rate limiting on every Quick Connect endpoint), ADR 0002 (bearer, not OAuth) **Amended 2026-10-08.** The owner raised the bound to ten a second when every plan cap went: "rate limit should be like 10 per second or something if it also includes agent read/writes". The default bound is now 600 a minute and 36,000 an hour, with 10 concurrent expensive operations. The hour holds exactly sixty full minutes, so a link may run at ten a second all hour. The forms a caller reaches with no credential keep 60 and 1,000 (`CREDENTIAL_BOUND`, passed per call as `RateLimitRequest.bound`), because a looser bound there buys a guesser more guesses. A link the public try door minted keeps the same bound (`linkRateBound`), because a stranger got it with no account. The rest of this ADR records the design as it was decided, with the old numbers. ## Context PRD §5.2 is specific: **60 requests per minute and 1,000 per hour, per session link, answered with `429` and a `Retry-After`.** S3 puts it on every Quick Connect endpoint. Those two sentences pin down more than they look like they do. **The link is the credential** (ADR 0002, PRD §5.2: "the URL is the credential"). There is no user, no session cookie and no OAuth subject on this door — the only identity a request carries is the token, so the token hash is the only thing a quota can be attached to. It is also the right unit: one link is one agent, one chat, or one CI job, and revoking a link is how a person turns one off. **The threat this defends against is a loop, not a botnet.** An attacker without the link cannot spend the quota, because `sp_` plus 32 base62 characters is not guessable and the hash is what is stored (S1). What actually happens is an agent that retries `recall` in a tight loop, or a CI job that forgets it already has the context — spending Workers AI inference, Vectorize queries and D1 reads on somebody's card. That shapes every trade-off below, and in particular it is why the failure mode is the one chosen at the end of this document. **Three properties are needed**, and no single Cloudflare primitive has all three: 1. **Two windows, one of them an hour.** The rule is not "a rate"; it is a short bound and a long bound, and the long one is what stops sustained abuse. 2. **A global count.** "60 per minute" that is really "60 per minute in each of thirty locations" is 1,800 per minute, and the number in the PRD would be a decoration. 3. **A cost that does not break NF9.** Quick Connect `AGENTS.md` is p95 under 200 ms. Two facts about the platform constrain the answer: - **The native Workers `ratelimit` binding cannot express the rule.** Its `simple.period` "must be either 10 or 60" seconds, so there is no hourly bound to configure at all — the second half of PRD §5.2 is not representable. And its counters are, in Cloudflare's own words, "local to the Cloudflare location that your Worker runs in", with the API "permissive, eventually consistent, and intentionally designed to not be used as an accurate accounting system". - **A Durable Object is the only globally-serialising primitive in the stack.** D1 is a single writable instance but every mutation on it belongs to `CommitService` by invariant I1 (ADR 0005), and a rate counter is not a `Change`: it has no blob, no provenance, no audit meaning, and it would put a write on every read path. KV is eventually consistent by construction. ## Decision **One Durable Object per session link, addressed by `getByName(tokenHash)`, holding two wall-clock-anchored fixed windows and a set of concurrency leases, consulted once per request.** `apps/worker/src/services/rate-limit.ts` holds the object and the arithmetic; `apps/worker/src/http/rate-limit.ts` turns a decision into HTTP. Six parts, each of which is a decision somebody could reasonably have made differently: ### 1. The object is per link, and the address is the token hash `env.RATE_LIMITER.getByName(tokenHash)` resolves to exactly one object worldwide, so every request for that link — from any Cloudflare location — is serialised through it. That is what makes 60 the limit rather than 60×N. It is deliberately **not** the documented Durable Objects anti-pattern of a single global limiter object. A global object is a chokepoint because it funnels unrelated traffic through one single-threaded instance. Here the address is the link, so the fan-out is the number of live links and the contention within one object is exactly the contention the quota is about. A link is the unit of quota, so it is the correct unit of coordination. The limiter is mounted **after** authentication, and only ever sees a token that resolved to a live `session_tokens` row. That ordering is load-bearing: keyed on an unvalidated token, anyone could make this Worker create an unbounded number of Durable Objects — and pay for them — by sending noise. Unauthenticated floods are a `401` from auth, and belong to the WAF, not to this file. ### 2. Fixed windows anchored to the wall clock, not sliding, not a token bucket `floor(now / windowMs) * windowMs`. Two Workers, the object, and the degraded fallback all compute the same window boundary without sharing anything, and `Retry-After` becomes a fact about the calendar rather than a fact about when this object happened to be created. A caller straddling a boundary can therefore make up to **2× the limit across a two-window span** — 120 requests inside a two-second straddle at the minute boundary. That is accepted, and it is bounded: the hourly window means the trick cannot be repeated for sustained abuse, and 120 requests in two seconds is not a cost event. *A sliding window counter* (weight the previous window by how much of it is still in view) removes that burst and was rejected on two grounds. It makes `Retry-After` an estimate — the honest answer is "when enough of the previous window has aged out", which is a fractional quantity that has to be rounded and then over- or under-states the wait. And it makes "60 succeeded, the 61st was refused" untrue in general: a caller who used 60 in the previous minute is refused for most of the next one, which is *more* correct and *less* like what PRD §5.2 says. The PRD says "60 requests per minute". This implements that sentence. *A token bucket / GCRA* was rejected for the same reason from the other side: with a bucket of 60 refilling at one per second, a caller who spends 60 and waits 30 seconds gets 30 more inside the same wall-clock minute — 90 in a minute, under a rule that says 60. ### 3. One round trip, carrying the whole decision `take({ now, expensive })` returns both window verdicts, the concurrency slot, the reset instant and the `Retry-After` in a single RPC. Checking the minute, then the hour, then the concurrency bound would have been three. The concurrency slot is handed back on a **second** call that never touches the response path: the middleware gives it to `executionCtx.waitUntil`, so the caller pays for one round trip and not two. ### 4. Time is a parameter `now` is passed in, never read from a clock inside the object. This is the same rule `packages/core/src/retrieval/types.ts` already holds to for `RetrievalQuery.now`, and it buys the same thing: the window boundary, the rollover, and the exact `Retry-After` are testable at a specific millisecond, with no fake timers and no sleeping. The only caller is this Worker talking to its own object over a private binding, so there is no untrusted party in a position to move it. ### 5. The two windows are persisted; the leases are not The windows go to the object's **synchronous** SQLite-backed storage (`ctx.storage.kv`) on every admitted request, so durability costs no additional round trip — only a local write the output gate flushes before the response leaves. They have to be persisted, because an idle object is evicted from memory and an in-memory hour counter would go with it: spend 999, go quiet long enough to be evicted, spend 999 more, all inside one hour. Leases are the opposite decision for the opposite reason. A lease asserts that a request is in flight *right now*; if the object was evicted, nothing it was tracking is still running, so restoring them would mean waking up holding slots for operations that have ended. Losing them frees slots, which is the safe direction, and a 30-second expiry already bounds how long a lost release can matter. ### 6. Four concurrent expensive operations per link `recall` costs an embedding, a Vectorize query and a rerank; `remember` and `edit` cost a commit. Sixty of those a minute is inside the per-minute bound and still enough concurrent inference to make every *other* request from the same team slow, because they queue behind the same account-level Workers AI capacity. Four is the width of the parallelism a real agent has — Claude Code, Codex and Cursor issue tool calls a few at a time — so it admits every legitimate pattern and catches a runaway loop by its fifth outstanding request. It is a queue depth, not a quota: the refusal's `Retry-After` is computed from when a slot actually frees, and every lease expires after 30 seconds so a Worker killed for a CPU limit cannot wedge the link. ## The failure mode: fail open, degraded, and loud **When the Durable Object throws, or does not answer within one second, the request is admitted under a weaker per-isolate limit rather than refused.** The case against fail-closed. Signpost exists so that an agent has the team's context while it works; the limiter is a cost control on a self-hosted instance whose credential is a link held by one team. Failing closed trades the availability of the entire product — every `recall`, every `read`, every editor session — against an overspend that is, by the threat model at the top of this document, a runaway loop rather than an attack. It also makes the limiter the least reliable component in the request path *and* the one whose failure is total, which is the worst possible place to put a hard dependency. The case against plain fail-open is that it is a blank cheque, and this is not one: - **The same arithmetic still runs**, in a module-scope map inside the isolate. That is precisely the per-location counting rejected for the native binding — the effective limit becomes 60 per minute *per isolate* — but as a floor during an outage it is enormously better than nothing, and it costs no round trip, which matters because the reason the object is unreachable may be that everything is slow. There is exactly one implementation of the counting, shared by the object, the fallback and the tests, so the degraded path cannot have its own bugs that only surface during an outage. - **It is visible.** The decision carries `degraded: true`, which becomes `X-Signpost-RateLimit: degraded` on the response, and the condition is logged. - **It is bounded in time.** A one-second timeout means a wedged object cannot hold a request open; the in-flight RPC is not cancellable, so the object may still count a request whose answer was ignored, which over-counts during an outage. That errs toward refusing, which is the direction to err in. - **It is bounded in memory.** The fallback map is cleared wholesale past 1,024 links rather than grown, because a module-scope map in a Worker lives as long as the isolate. Tested in `apps/worker/test/services/rate-limit.test.ts`: an unreachable object still serves; the degraded limiter still refuses the 61st request in a minute; a wedged object degrades after the timeout rather than hanging; and the authoritative counters take over again the moment the object answers. ## The cost, measured against NF9 **Measured: 0.76 ms per `take` call**, mean over 50 sequential calls against the real Durable Object in workerd (`apps/worker/test/services/rate-limit.test.ts`, "the round trip the durable object adds"). That covers RPC dispatch, argument and result serialisation, the window arithmetic and the storage write. It does **not** cover the network, which does not exist in a local run, so treat it as a floor and as a regression guard on the part that is ours: if `take` ever grows a storage read, an await per window, or a second round trip, that test is where it shows up. The network term is the real one, and the honest accounting is: | Path | Limiter cost | Against NF9 | | --- | --- | --- | | `AGENTS.md`, edge cache hit | **zero** — the Worker is not invoked | p95 is a cache hit, so NF9 is unaffected | | `AGENTS.md`, cache miss, object in-region | ~5–15 ms | Comfortable | | `AGENTS.md`, cache miss, object on another continent | ~150–250 ms | Would breach NF9 on that request | | `recall` | one round trip | Small against NF1's 800 ms, which includes an embedding and a rerank | The third row is the exposure, and the mitigation is structural: a Durable Object is placed near the request that created it, and the first request for a link on a self-hosted single-team instance comes from that team. `getByName` accepts a `locationHint`, which is the documented remedy if an operator's first request ever comes from somewhere unrepresentative. The condition is self-diagnosing — `AGENTS.md` p95 on cache misses is the number to watch — and this is recorded here rather than discovered later. ## Alternatives considered ### The native Workers `ratelimit` binding — rejected, and it is not close Cheapest by a distance: no storage, no object to bill, no round trip, one `limiter.limit({ key })` call. Rejected on two independent grounds, either of which is sufficient. 1. **It cannot express the rule.** `simple.period` must be 10 or 60 seconds. There is no 1,000-per-hour limit to configure. Implementing PRD §5.2 with this binding would mean implementing half of it and writing the other half somewhere else — at which point the somewhere else is the mechanism, and this is an optimisation on top of it. 2. **The counters are per-location.** Cloudflare documents them as local to the Cloudflare location the Worker ran in, and describes the API as not intended for accurate accounting. Across N locations the effective limit is roughly N×60. **Is that acceptable here?** No — and the reason is specific rather than a matter of taste. The client this limit exists to bound is an *agent*, and agent traffic is not one connection from one place: a CI matrix, a team of six engineers on three continents, and a hosted assistant calling on their behalf are routinely N different locations sharing one link. The multiplier is not a rounding error in the case the limit was written for; it is proportional to exactly the thing that makes the limit necessary. A limit that is 60 for a single laptop and 600 for the CI fleet is not a limit, it is a formality. It would be acceptable for a *coarse* bound where the threshold is chosen an order of magnitude above legitimate use — which is what a WAF rate-limiting rule on unauthenticated `/s/*` traffic should be, and that is a deployment concern rather than this Worker's. ### Workers KV with a TTL — rejected Cheap and needs no object, and wrong in exactly the dimension that matters. KV reads are served from a local cache with a TTL that is not invalidated on write; a value written in one location is not reliably visible in another for up to a minute, which is the entire minute window. Under the burst this is supposed to catch — the tight retry loop, arriving as fast as the network allows — the counter a request reads is the one that has not been updated yet. It also has no read-modify-write: two concurrent increments lose one, without any way to notice. ### A counter row in D1 — rejected D1 is a single writable instance, so the count would be globally correct, and a conditional `UPDATE … WHERE count < 60` is a real compare-and-swap. It was rejected because it puts a **write** on every read path — and because invariant I1 (PRD §3.7, ADR 0005) says every mutation goes through `CommitService.commit(ctx, change)`. A rate counter is not a `Change`: it has no content-addressed blob, no provenance, no audit meaning and no index job. The honest options were to weaken I1 or to add a second write path with an allowlist entry, and neither is worth a counter. `ProjectContext` carries a read-only `db` precisely so that this question has one answer. ### Reusing `NoteDoc` or another existing Durable Object — rejected `NoteDoc` is addressed by permalink and is phase 5's Yjs document; a limiter is addressed by token hash. There is no shared address, so there is nothing to reuse — the two would be different objects in the same class, which is worse than two classes because the class would then hold two unrelated kinds of state with two different lifecycles. ### Counting refused requests against the quota — rejected A caller in a retry loop hammering a limit it has already hit is not charged for those requests. With fixed windows the reset instant does not move either way, so charging them would buy no extra protection; it would only make `X-RateLimit-Remaining` report a number that describes no possible future. Only admitted requests are counted. ## Consequences - **One Durable Object per live session link**, holding two small values. At the PRD's own ceiling of 1,000 requests per hour per link, the object cost is 1,000 requests and 1,000 tiny writes an hour per link — immaterial next to the Workers AI inference the same traffic causes. - **Every Quick Connect request that reaches the Worker pays one round trip.** `AGENTS.md` cache hits do not reach the Worker and pay nothing, which is why NF9's headline number is unaffected. - **`Retry-After` is exact**, not an estimate, and it is the *longest* wait among the bounds that refused — telling a caller over both bounds to retry in 12 seconds would only refuse them again. - **Nothing the limiter produces is cacheable.** Every `429`, `413` and `400` carries `Cache-Control: no-store` and `CDN-Cache-Control: no-store`, and `apps/worker/test/http/rate-limit.cache.test.ts` asserts against the real Cache API that a refusal is neither stored nor able to replace a briefing that already is. Per-caller counters are omitted from any response that asked to be cached, because `X-RateLimit-Remaining: 3` served from the edge to a caller who has used nothing is worse than no header at all. - **The shape bounds are derived, not chosen.** `MAX_REQUEST_BODY_BYTES` is `MAX_MARKDOWN_LENGTH * 4`, and the write schemas reuse `MAX_MARKDOWN_LENGTH` and `MAX_RELATIONS_PER_NOTE` from `services/commit.ts` rather than restating them, so the door and the write path cannot drift apart. `MAX_BLOCKS_PER_NOTE` cannot be checked before parsing and stays where it is enforced, in `commit`; it is published in `REQUEST_LIMITS` so an agent can respect a bound it has not hit yet. - **The degraded mode is a documented operating state**, not an incident: `X-Signpost-RateLimit: degraded` on responses and a warning in Workers Logs mean "the limiter is per-isolate right now", and it heals by itself. - **Adding a second door does not mean adding a second limiter.** `checkRateLimit` and `releaseRateLimit` take a namespace, a key and a request; the Hono middleware is a thin wrapper. The MCP door, which is JSON-RPC over one HTTP response and cannot use a status code per tool call, reports the same `RateLimitDecision` as an application-level error.