Skip to content
DomainKit
Esc
↑↓navigate↵open⌘Jpreview
On this page

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.

Last updated on

Was this page helpful?