Plans, approval, and receipts
How a plan is built from your requirements, what its four operations mean, what an approval binds it to, and what a receipt proves about an apply.
A plan is a snapshot of one zone rendered as exact operations, with a SHA-256 digest over all of them. Nothing is written while a plan is built: planning is a read.
Requirements
A requirement is a DNS record your product needs, its conflict policy, and an optional purpose
the customer sees.
export const requirements = [
DnsRecord.cname({
name: "app.example.com",
target: "edge.acme.dev",
purpose: "Serve your site",
}),
DnsRecord.txt({
name: "_acme.app.example.com",
value: "acme-verify=7f3a",
purpose: "Prove ownership",
}),
];policy defaults to exclusive for CNAME and append for everything else. An exclusive
requirement means no other record may sit at that name and type, and an exact match doesn’t excuse
an incompatible neighbour; an append requirement tolerates neighbours.
DnsRecord.spf({ name, value, ttl?, purpose? }) is a TXT requirement with an explicit SPF
constraint. It appends beside unrelated TXT such as verification tokens, and it conflicts when the
name holds a different SPF record, more than one SPF record (identical duplicates included), or
another requested SPF value. Only the v=spf1 version token is validated; DomainKit doesn’t merge,
replace, or fully parse SPF. A generic DnsRecord.txt stays an append requirement, including one
whose value is SPF.
Every requirement must sit at or below the attached domain. A name outside it fails InvalidInput
before the planner reads the zone, so tenants sharing a zone cannot plan into each other’s names.
Building the plan
// Reads the attached zone and returns the exact operations, never a write.
export const build = Provision.plan({ domain: "app.example.com", requirements });The four operations
| Operation | Means |
|---|---|
Create |
The record is missing and will be created |
Noop |
An exact match exists and no constraint is violated. ttlDrift says whether its TTL differs |
Conflict |
Incompatible state, with a reason for what is in the way |
Delete |
Cleanup only: remove a record a receipt proves DomainKit created |
| Conflict reason | Raised when | By |
|---|---|---|
cname-collision |
A CNAME would share a name with another record, in either direction | Provisioning |
opaque |
A record at that name and type is one DomainKit cannot model | Provisioning |
exclusive-name |
An exclusive requirement meets a different record at that name and type | Provisioning |
spf-conflict |
An SPF requirement meets a different SPF record or more than one | Provisioning |
missing |
The record a receipt names is already gone | Cleanup |
value-mismatch |
The record a receipt names no longer matches what was written | Cleanup |
The planner is additive. DomainKit never updates, overwrites, or deletes a record it did not create, so a conflict is reported and the write does not happen.
/** What a review screen needs: the writes, the conflicts, and whether apply can run at all. */
export const review = (plan: Plan.Model) => ({
writes: Plan.writes(plan),
conflicts: Plan.conflicts(plan),
applicable: Plan.isApplicable(plan),
digest: plan.digest,
expiresAt: plan.expiresAt,
instructions: Plan.renderInstructions(plan),
});Plan.renderInstructions(plan) renders the same operations as copyable lines, for a host whose
customers apply records by hand.
Approval
An approval binds consent to the plan’s digest and to an explicit list of operation ids.
/** Approval binds consent to the digest. Without `operationIds` it covers every write. */
export const approveEverything = (plan: Plan.Model) => Provision.approve(plan);
/** Partial approval names the operations and admits that conflicts stay behind. */
export const approveSome = (plan: Plan.Model) =>
Provision.approve(plan, {
operationIds: Plan.writes(plan)
.slice(0, 1)
.map((operation) => operation.id),
allowPartial: true,
});A plan with conflicts is approved only with allowPartial and a conflict-free selection. The digest
ignores purpose labels, so relabelling a requirement never invalidates consent the customer
already gave.
Approving an already-approved plan returns the same approval, so a retried request is safe.
Declining
/** Declining is terminal for that plan and leaves the domain free for a new one. */
export const decline = (plan: Plan.Model) =>
Provision.reject(plan, { reason: "Customer wants to keep the current CNAME" });A rejection is terminal for that plan: approving or applying it afterwards fails Stale. The domain
itself stays free, so the next plan starts clean.
Apply
/**
* Apply re-plans the zone first and fails `Stale` when it moved. Partial success is a receipt with
* `status: "partial"`, not a failure: re-planning turns the written records into no-ops.
*/
export const applyAndSummarise = Effect.gen(function* () {
const plan = yield* build;
const approval = yield* Provision.approve(plan);
const receipt = yield* Provision.apply(approval);
return {
complete: Receipt.isComplete(receipt),
written: Receipt.applied(receipt).length,
outcomes: receipt.outcomes.map((outcome) => outcome._tag),
};
});Apply re-plans the zone before the first write and fails Stale when the digest moved, then
revalidates each approved create before writing it. That narrows the race between the last read and
the write; it cannot remove it.
Receipts
A receipt lists one outcome per operation.
| Outcome | Carries |
|---|---|
Applied |
The operation id and the provider’s id for the record it wrote |
Skipped |
noop, not-approved, or not-attempted |
Failed |
The operation id and the provider’s message |
A failure after a confirmed write is not an exception. Apply stops there, earlier writes stay
Applied, later approved operations become Skipped(not-attempted), and the receipt’s status is
partial. Re-planning turns the written records into no-ops and the rest into creates.
A failure before any write marks the attempt failed and raises. The next apply re-claims it.
Three requests, one attempt
Every step is a stored attempt, so a host can render the plan in one request, collect consent in another, and apply in a third.
/** Plan, consent, and apply can land in three requests; each step replays its own result. */
export const resume = (planId: Plan.PlanId) =>
Effect.map(Provision.get(planId), (attempt) => ({
status: attempt.status,
approved: attempt.approval !== null,
receipt: attempt.receipt,
declinedBy: attempt.rejection?.actorId ?? null,
}));| Status | Means |
|---|---|
planned |
Built and waiting for consent |
approved |
Consent recorded, not yet applied |
applying |
An apply holds the lease |
complete |
Every approved write landed |
partial |
A write failed after an earlier one landed; re-plan to finish |
failed |
Apply stopped before any write |
expired |
The plan aged out before it was applied |
rejected |
The customer declined |
A claim while the apply lease is still live fails Busy. A plan lives one hour and an apply lease
two minutes by default; Provision.Policy changes both.
When it goes wrong
/** Every failure is one `DomainKit.Error`; the reason says what to do next. */
export const explain = applyAndSummarise.pipe(
Effect.catchTag("DomainKitError", (error) =>
Match.value(error.reason).pipe(
Match.tag("Conflict", ({ operations }) =>
Effect.succeed(`Fix ${operations.length} conflicting record(s) first`),
),
Match.tag("Stale", () => Effect.succeed("The zone moved; build a new plan")),
Match.tag("Expired", () => Effect.succeed("The plan aged out; build a new one")),
Match.orElse(() => Effect.succeed(error.message)),
),
),
);Failure reasons maps every reason to the next step.
Several domains reviewed and approved together are a batch, built from these same attempts.
The domainkit reference lists every namespace the lifecycle is built from.