The button that does not generate
When Dad presses Review generation in the studio, nothing is generated. The studio calls one function, plan(), and shows what it returned. When he then presses Generate images, the studio sends the very same request body, with the same request id, and the engine calls plan() again before it stores anything. Preview and submission share that code path: POST /api/generation/preview returns the plan without Ember's catalog, plus the exact prompt and files of the first planned output, and POST /api/generation runs plan() inside generate() and answers 202 with the batch. Pippa's gateway, /internal/generation, goes through the same function.
A plan is the whole request written out before anything is spent. Every task is one image: a frame, a candidate number and a generator. Each task carries its resolved provider, its output size, the billing kind Ember lists for that row, the composed prompt, every reference by id and SHA-256, the depicted subjects, the acting soul and, for the automatic generator, Ember's routing decision. Nothing about the request is left for the worker to work out later.
Refused by name, before a batch exists
Most of plan() is refusals. Each one is a StudioError carrying a sentence a person can act on, and the app returns it as a 422. A blocked provider connection comes back as a 503 with Ember's own message. Roughly in the order the code meets them:
- Clip settings on an image request. Clips plan on their own path, which the Clips Are Paid track covers.
- An aspect ratio outside Ember's vocabulary.
- The same generator chosen twice: Choose each provider once; use the candidate count for repetitions.
- A set workflow (create, regenerate, remix) with no set, and frame names that repeat, break the frame pattern or do not belong to the set.
- A clip offered as an image reference.
- A generator Ember no longer lists, a blocked connection, or a size or quality the row does not offer.
- An aspect ratio that a selected generator's own list leaves out.
- A remix frame with no mapped original, and more references than the model declares it accepts.
- Subjects the acting soul may not depict, checked with Pippa.
- More tasks than the batch cap.
All of this happens before a single row is written. A refused request leaves no batch, no task and no job behind, and the aspect-ratio test asserts exactly that after its refusals: the batch list and the task list are both empty.
A typo must stop at review
The request contract bounds the candidate count to 1 through 50 per generator, and the studio's number field clamps to the same range. That does not bound the batch, because candidates multiply. The architecture doc's own example: two 15-frame collections, two generators and five candidates come to 300 images. One request names at most one set, though, so in today's planner that is two batches of 150, one per collection. A slip of one key in either, 50 candidates instead of 5, plans 1,500. So a second bound sits on the product: max_batch_tasks, 600 by default, read from AVATARS_MAX_BATCH_TASKS. The comment beside it gives the reason in one line: a typo in the candidate count must stop at review, never at the provider's invoice.
The check sits inside the planning loop, so the plan stops as soon as task 601 appears and never builds the rest. Its sentence says what to change: reduce candidates, frames or generators, or split the request into several batches. The test plans a 20-image remix, lowers the cap to 10 and expects more than 10 images, then shows that a count of 500 never reaches the plan at all, because the contract refuses it while parsing.
One check per subject set
Some checks ask another service. Whether the acting soul may depict a subject is Pippa's policy, so the plan asks Pippa. It asks once per distinct set of subjects and keeps the answer for the rest of the plan. The same test counts the calls: a 20-task plan validates Pippa's subject set at most six times. A preview that made a network round trip per task would be too slow to use, and a check nobody wants to wait for is a check people learn to skip.
From plan to intent, in one transaction
generate() takes the studio's intent lock, asks whether this request id has been seen, plans again, and writes the batch and every task in one SQLite transaction together with the request id. Either the whole tree exists or none of it does: a test makes the insert fail partway and finds no batch, no task and no request id left. The same request id with the same input returns the batch it already made, so a double press of Generate images makes one batch. The same id with different input is a conflict. The Record Keeper track covers that storage.
What matters here is that the plan becomes the intent unchanged. Each task is written as queued with an empty attempt list, and the worker reads only what the plan froze, down to the reference hashes it checks again before it sends a single byte to Ember.