An export is a download right up to the point where it can fail halfway. That is where the boundary sits, and file size does not draw it: a request that streams rows as it selects them dies with the connection, while a job that writes a file somewhere else acquires a lifecycle somebody has to read.
The line is drawn where a request can fail halfway
Two entry points answer the same question here, and they answer it differently because one of them can hold the answer inside the response and the other cannot.
The direct download counts the matching rows, then streams the CSV into the response as it selects it. Nothing is stored, no job row is written, and if the connection drops the work is simply gone. There is no half-finished artifact for anybody to find later, and backpressure comes for free, so a slow client cannot make the server hold a whole file in memory while it waits.
The background mode inserts a job, hands back an identifier, and generates the file straight into object storage without ever holding it whole. The caller polls. What comes back is a state, and once that state is done, a signed url.
The caller picks which of the two it asks for. What the row count decides is narrower: whether the direct download is allowed to proceed, and above 50,000 rows it is refused instead of served slowly. The background path carries no row ceiling at all; what bounds it is a cap on the size of the artifact.
| What the caller asks for | What comes back | What outlives the request |
|---|---|---|
| A direct download under the row ceiling | the CSV itself, streamed as it is selected | nothing: no job row, no stored file |
| A direct download over the ceiling | 413 carrying the row total and where to go instead | nothing, and not one row was generated |
| A background job | an identifier and a pending state | a job row, then an artifact with an expiry |
| A background job while three are in flight | 429 saying to retry once one finishes | nothing |
Count the rows before generating one of them
The interesting decision is not where the ceiling sits. It is when the ceiling is checked.
An export endpoint has an obvious lazy design: start generating, and give up when the thing gets too big or the request runs too long. That design pays for most of the work before it fails, and it hands back something nobody can act on. A dead connection and a truncated file both refuse to say how large the job actually was, so the caller’s only move is to try again with a narrower guess.
Both entry points here count first. The count is its own query, it runs before a single row is selected for output, and its result goes into the refusal: the caller is told how many rows the export has, what the ceiling for a direct download is, and to run it as a background job instead. A refusal carrying the measurement is one somebody can plan the next call from.
The half worth copying is about the door rather than the number. That count runs behind the same permission check the generator runs behind, and it has to, because the count is a reading of the project’s contents. A design that counted first and authorized second would answer a stranger with a precise row total, which is a yes-or-no on whether the project exists and a rough sizing of what is in it. Counting behind the role check makes a stranger’s over-cap request indistinguishable from a request for a project nobody ever created.
That generalizes past exports. Any endpoint whose error message carries a measurement has to take the measurement behind the same door as the work it is refusing to do, because a 413 quoting a number and a 429 quoting a remaining allowance are both readings of private state that happen to leave as error codes. The other half of the same discipline decides who may press export at all, and the second attack surface in a ticket export covers that from the reader’s end of the file.
Five states, and what each one tells the caller
A job has five states. They are worth reading from the caller’s side rather than the queue’s, because four of them are answers to a poll and the fifth is what a working link turns into.
| State | What a poll returns | What the caller does with it |
|---|---|---|
pending | the job exists, nothing has claimed it yet | poll again |
running | a generator is writing the artifact | poll again |
done | row count, byte size, and a signed url with its expiry | download before the expiry |
failed | an error string, truncated to a bounded length | read it, narrow the request, ask again |
expired | no url; the artifact has been deleted | request the export again |
The fifth state is the one that would be easy to leave out, and leaving it out costs more than it saves. An expired export could have been a row that simply disappeared once its file was purged. Keeping the row, with the file gone and the path cleared, is what lets somebody tell an export that was made and has since been cleaned up from one that never happened. For a file of personal data that distinction is the whole audit trail, and it is the same reasoning the rest of the security model runs on.
Two smaller decisions live in that table. A failed job keeps its error rather than a bare status, because a caller reading failed with nothing attached has to guess between a bad filter and a broken service. And a job that finishes successfully clears any error written while it was running, since a done row carrying an error message is a state no client can interpret.
Why a failed export waits for a person
Retrying is the right default for a job whose entire product is one idempotent call to somebody else’s API. The attempt costs a request, the failure is usually transient, and nobody has to be told.
An export is a different shape, and the divergence is on the record as a deliberate one rather than an oversight: a running export orphaned by a crash is marked failed after ten minutes rather than reclaimed and retried, because at its cost profile a silent auto-retry is worse than an honest failure the requester can see and act on.
The mechanical half of that is sharper than the economic half. The artifact is streamed to storage out of a generator, and a generator is single-shot: once its chunks have been consumed they are gone. The storage write is therefore configured with retries: 0, because replaying an exhausted generator does not reproduce the file, it publishes a shorter one. A truncated CSV is the most expensive failure in this whole design, because the file opens, the columns are right, and nothing in it records the rows that never made it.
So the failure is left where the person who asked for it can see it. The row keeps its error, the poll returns it, and the next attempt is a decision made with the message in hand. The cost of that choice is worth stating rather than hiding: a background export interrupted by a restart does not resume, and whoever requested it has to request it again.
The limits a caller plans around
Every constant in this design is only interesting as something the caller receives.
| Limit | What the caller sees | What to do about it |
|---|---|---|
| 50,000 rows in one direct download | 413 with the real row total | run it as a background job, which has no row ceiling |
| 3 pending or running jobs per project | 429 saying to retry once one finishes | serialize the requests, or wait for a slot |
| 64 MiB per artifact | the job ends failed, naming the storage cap | narrow the filters, split the date range |
| 24 hours of artifact life | the url stops working, the job reads expired | download inside the window, or ask again |
| 10 minutes for an orphaned running job | the job flips to failed on its own | ask again; nothing resumes by itself |
The 24 hours is the row worth arguing with, because it is shorter than an ordinary attachment gets here and shorter on purpose. An export is bulk personal data in one file behind a link that carries no session, which is exactly what makes the link usable by an agent holding no cookie and exactly what makes a long life expensive. A short window bounds what a leaked url is worth, and the trade lands on whoever downloads it a day late. The clock belongs to the artifact rather than to the link: a signed url gets its own 24 hours from the moment the poll mints it, so a late one outlives the file on paper and stops working because the collector has deleted the object under it.
None of this is two sets of rules. The panel route and the agent tool go through one implementation of the boundary, which is a shipping rule here rather than a good intention: every capability the panel has is also a tool on the MCP server. An agent cannot receive a stream, so synchronous for an agent means generate now and return a url. It still passes the same count, meets the same ceiling, consumes the same quota and leaves a row that expires on the same clock. A boundary drawn twice is two boundaries, and they drift.