Setting up many domains at once
How a batch plans several domains together, binds one digest to one consent, applies with bounded concurrency, and resumes whatever did not land.
A customer moving several domains reviews them as one thing. A batch is that unit: plan every domain, show one digest, take one consent, apply with bounded concurrency, and come back later for whatever did not land.
A batch owns nothing a single domain already has. Each item is a pointer to the attachment and to the attempt carrying that domain’s plan, so the plan, the approval, the receipt, the lease, and the failure all live where they always did.
Planning
/**
* Plans every domain at once, with `Policy.batchConcurrency` in flight. The key is unique per
* owner, so a retried request answers with the batch the first one made.
*/
export const start = Provision.batch.create({
idempotencyKey: "setup-2026-03-04-a41f",
items: [
{ domain: "one.example.com", requirements: requirementsFor("one.example.com") },
{ domain: "two.example.com", requirements: requirementsFor("two.example.com") },
],
});Every domain must already be attached. Planning reads a provider per domain, so
Policy.batchConcurrency bounds how many run at once; it defaults to 4.
A domain that cannot be planned does not stop the ones beside it. Its item records planFailure,
the batch stays planning, and nothing is lost.
/** What a review screen needs: the digest to approve, and where each domain stands. */
export const review = (batch: Provision.Batch) => ({
digest: batch.digest,
status: batch.status,
domains: batch.items.map((item) => ({
attachmentId: item.attachmentId,
plan: item.plan,
status: item.status,
// The one thing an item owns: an item with no plan has no attempt to carry it.
planFailure: item.planFailure,
})),
});/**
* Re-plan the domains that have none. A batch holds pointers, not requirements, so the host
* supplies them again.
*/
export const resume = (id: Storage.BatchId) =>
Provision.batch.resumePlanning(id, {
items: [{ domain: "two.example.com", requirements: requirementsFor("two.example.com") }],
});Resuming plans only the items that have none. A batch stores pointers, not requirements, so the
host supplies the requirements again — the same shape create takes.
One digest, one consent
A batch’s digest is a SHA-256 over its items’ sorted attachmentId:planDigest pairs, so it moves
whenever any domain’s plan moves. It is null until every item is planned, and that is exactly when
a batch is approvable.
/**
* One consent for the whole batch, bound to the digest the customer read. A digest the batch's
* current plans no longer produce fails `BatchStale` and writes nothing.
*/
export const approve = (batch: Provision.Batch) =>
batch.digest === null
? Effect.succeed(batch)
: Provision.batch.approve(batch.id, { digest: batch.digest });Approval writes one Approval per attempt, each bound to that attempt’s own plan digest, together
with the batch’s own approval in a single transaction. A batch is never approved without the
per-attempt approvals apply takes, and an attempt is never approved for a batch that was not.
A digest the batch’s current plans no longer produce fails BatchStale and writes nothing. Read the
batch again and show the customer what moved.
Applying
/**
* Applies every approved domain that has no receipt yet. A domain another apply holds waits for
* the next round, and one that fails does not stop the ones beside it, so calling this again
* resumes the batch.
*/
export const apply = (id: Storage.BatchId) => Provision.batch.apply(id);Apply walks the approved domains with Policy.batchConcurrency in flight, each under its own
attempt lease. Two applies of the same batch never write the same record twice: the one that loses
a lease finds that domain Busy and skips it.
A domain that fails records its failure on its own attempt rather than stopping the others, so
calling apply again re-claims it. A domain whose write failed after an earlier one landed has a
partial receipt and needs a new plan, exactly as it would on its own.
Where a batch stands
| Status | Means |
|---|---|
planning |
At least one domain has no plan yet |
planned |
Every domain is planned and the digest is ready to approve |
approved |
Consent recorded, nothing applied yet |
applying |
An apply is in flight |
complete |
Every domain is done |
partial |
A domain’s write failed after an earlier one landed; re-plan |
failed |
A domain stopped before any write; apply again |
rejected |
The customer declined, and every plan under it with them |
The status is recomputed from the items’ attempts on every transition, so it never disagrees with
the domains it summarizes. complete and rejected are terminal.
/** The index behind a "you still owe this" banner: no plan is read, so it stays cheap. */
export const unfinished = Provision.batch.list({ unfinished: true });list({ unfinished: true }) reads no plans, which is what makes a “you still have a setup waiting”
banner cheap. Open one batch and Provision.batch.get pays for that one.
Declining
/** Terminal, and it declines every plan under the batch. Only while nothing is approved. */
export const decline = (id: Storage.BatchId) =>
Provision.batch.reject(id, { reason: "Wrong domains" });Rejection is terminal, and it declines every plan under the batch. Declining again returns the same
batch; a batch that was already approved fails BatchStale.
A planning pass runs outside any transaction, because it reads a provider. Landing its result checks the batch’s state in the same transaction as the write, so a pass still in flight when the customer declines lands nothing.
Retrying a create
idempotencyKey is unique per owner. A retried create — a double-clicked button, a client that
resent the request — answers with the batch the first call made instead of planning the same domains
twice. Over HTTP the key is the Idempotency-Key header on POST /batches; the
domainkit/server reference lists every batch route.
export const explain = (batch: Provision.Batch) =>
Match.value(batch.status).pipe(
Match.when("planning", () => "Some domains still need a plan; resume planning."),
Match.when("planned", () => "Ready for the customer to approve."),
Match.when("approved", () => "Approved; apply it."),
Match.when("applying", () => "An apply is in flight."),
Match.when("partial", () => "Some records landed and some did not; re-plan those domains."),
Match.when("failed", () => "At least one domain stopped before any write; apply again."),
Match.when("complete", () => "Every domain is done."),
Match.when("rejected", () => "The customer declined."),
Match.exhaustive,
);