DEV Community

FinnOakley52947
FinnOakley52947

Posted on

Node.js API Domain Retirement Explained with 3 Shared Zone Risk Controls

Short answer: during domain offboarding, delete only the SPF, DKIM, and DMARC records owned by that customer-support mail domain; remove the whole zone only when an ownership check proves that the zone is dedicated, empty of unrelated records, and explicitly approved for destruction.

The deciding constraint is propagation delay versus cutover speed. A fast control-plane response doesn't flush recursive DNS caches, while a zone deletion has a much larger blast radius than a record-set deletion. Treat the operation as a state transition with evidence, not as a DELETE button wired straight to production.

This is boring on purpose.

Should a domain offboarding API delete records or remove the whole shared zone?

The default should be record-level retirement. In a shared zone, the zone is infrastructure; the SPF, DKIM, and DMARC record sets are tenant-scoped configuration. Those are different ownership boundaries even if one API exposes both as deletable resources. A support platform that sends mail for help.example.com may share example.com with the company website, inbound mail, certificate validation, and another team's status page. Deleting the zone to clean up three authentication records confuses a small lifecycle event with destruction of the container.

Whole-zone removal belongs behind three controls: the zone inventory contains no records outside the offboarding manifest, the ownership registry marks the zone as dedicated to the departing tenant, and a separately recorded approval authorizes zone destruction. All three must pass. A record count alone is weak evidence because an empty-looking result can come from pagination, a stale snapshot, or querying the wrong account or DNS view.

There is a standards wrinkle too. SPF publishes policy in a TXT record and requires a domain name to have no more than one SPF record; multiple records produce a permerror. DKIM locates a public key below a selector such as selector._domainkey.example.com. DMARC publishes policy at _dmarc.example.com, and receivers can discover an organizational-domain policy when a subdomain has no applicable record. Removing a subdomain's DMARC record can therefore change the effective policy rather than merely turning DMARC off. The plan needs to capture names, types, and exact values before mutation.

So the API rule is blunt: shared means records only. Dedicated means records first, observe, then consider the zone.

The constraint that changes the cutover

DNS deletion is not globally instantaneous. Authoritative state can change now while recursive resolvers continue serving cached data until its TTL expires. Negative answers can be cached as well. That makes the previous TTL, not the speed of the delete request, the useful lower bound for an observation window. Don't claim completion because the write API returned success.

Mail adds a second clock. Messages already accepted or queued may still be evaluated later, and DKIM verification needs the public key selected by the message's s= tag. The correct retention interval depends on the actual queue and retry behavior in the sending and receiving path. I'm not sure what that interval is for an arbitrary support stack; delivery logs, queue settings, and the published TTL are the evidence that resolves it. Guessing a universal number would turn a cautious runbook into folklore.

Use a two-stage cutover. First, stop new sending for the domain and record a timestamp. Keep authentication records available while known queued mail drains, then issue the scoped DNS changes. After at least the relevant TTL and operational retention window, query multiple independent recursive resolvers and the authoritative servers. The checks answer different questions: authoritative queries show whether the source changed, while recursive queries show what clients can still observe.

Watch actual signals during that interval — mail volume by envelope domain, DKIM selector use, DMARC aggregate reports, and DNS answers for the exact names in the manifest. A NXDOMAIN for a selector is meaningful only when the queried selector is the one used by recently sent messages. A green dashboard built from _domainkey alone proves little because DKIM keys live below selector-specific names.

Fast is measurable. “Immediately” isn't.

A minimal TypeScript implementation

The smallest useful implementation separates planning from applying. It also makes the dangerous operation structurally harder to call: normal offboarding accepts an explicit manifest, while whole-zone retirement requires a dedicated-zone assertion and approval ID. The generic interface below is deliberately detached from a commercial route shape.

type RecordType = "TXT" | "CNAME";

type ExpectedRecord = Readonly<{
  name: string;
  type: RecordType;
  values: readonly string[];
}>;

type ZoneSnapshot = Readonly<{
  zoneId: string;
  records: readonly ExpectedRecord[];
}>;

interface DnsControlPlane {
  snapshot(zoneId: string): Promise<ZoneSnapshot>;
  deleteRecordIfUnchanged(
    zoneId: string,
    expected: ExpectedRecord,
  ): Promise<void>;
  deleteEmptyDedicatedZone(
    zoneId: string,
    approvalId: string,
  ): Promise<void>;
}

type OffboardingPlan = Readonly<{
  zoneId: string;
  dedicatedZone: boolean;
  approvalId?: string;
  ownedRecords: readonly ExpectedRecord[];
}>;

function sameRecord(a: ExpectedRecord, b: ExpectedRecord): boolean {
  return a.name === b.name &&
    a.type === b.type &&
    [...a.values].sort().join("\n") === [...b.values].sort().join("\n");
}

export async function retireMailDomain(
  dns: DnsControlPlane,
  plan: OffboardingPlan,
): Promise<"records-deleted" | "zone-deleted"> {
  if (plan.ownedRecords.length === 0) {
    throw new Error("Refusing an empty offboarding manifest");
  }

  const before = await dns.snapshot(plan.zoneId);
  const missing = plan.ownedRecords.filter(
    expected => !before.records.some(actual => sameRecord(actual, expected)),
  );

  if (missing.length > 0) {
    throw new Error(`Snapshot mismatch for ${missing.length} record set(s)`);
  }

  for (const expected of plan.ownedRecords) {
    await dns.deleteRecordIfUnchanged(plan.zoneId, expected);
  }

  if (!plan.dedicatedZone) return "records-deleted";

  if (!plan.approvalId) {
    throw new Error("Dedicated-zone deletion requires an approval ID");
  }

  const after = await dns.snapshot(plan.zoneId);
  if (after.records.length !== 0) {
    throw new Error("Dedicated zone still contains records");
  }

  await dns.deleteEmptyDedicatedZone(plan.zoneId, plan.approvalId);
  return "zone-deleted";
}
Enter fullscreen mode Exit fullscreen mode

The important method is deleteRecordIfUnchanged. Its adapter should use the DNS provider's compare-and-delete facility when one exists, or implement an equivalent precondition around a fresh read. The expected value protects a recently rotated DKIM key or edited SPF policy from being deleted by an old offboarding job. It also makes retries easier to reason about: a mismatch stops the run and forces a new plan instead of silently deleting whatever occupies the name now.

The catch is that this sample is intentionally narrow. It is not suitable when several systems can mutate DNS without a shared lock or version token; use a serialized change pipeline or provider-supported optimistic concurrency there. It also doesn't discover selectors from historical mail. Feed the manifest from the same verified ownership registry and sending configuration used by the support platform, then reconcile it against observed selector use before approval.

What I would change at scale

At small volume, a human-readable plan plus exact-value preconditions is enough. At hundreds of offboardings, I would make the plan an immutable artifact: zone identity, account identity, DNS view, requested record sets, snapshot digest, requester, approver, and expiration time. Plans should expire because approval against Monday's snapshot says nothing about Friday's zone.

I would also split permissions. The worker allowed to delete listed record sets should not automatically have permission to delete zones. A separate, rarely used worker can handle dedicated-zone destruction after the post-delete inventory is empty. This costs an extra credential and a little config — config bloat I normally dislike — but here the extra boundary maps directly to blast radius, so it earns its keep.

The state machine stays compact: sending_stopped, draining, ready, records_removed, observing, and optionally zone_removed. Each transition stores evidence and is idempotent. Failed preconditions return the job to planning; they do not trigger a broader delete. Rate limits and client timeouts remain ambiguous until a read confirms state, so retries start with observation rather than another mutation.

Keep the metrics equally plain. Measure time from sending shutdown to an approved plan, from apply to authoritative convergence, and from apply to the last stale recursive answer. Break those measurements out by previous TTL. One blended “offboarding latency” percentile hides the exact lever the team needs to adjust.

Trade-offs and the final decision rule

Record-level deletion takes more bookkeeping. You need an ownership manifest, exact-value comparisons, selector awareness, and a reconciliation step. It can also leave unrelated records behind in a dedicated zone if the inventory is incomplete. Whole-zone deletion is operationally simpler and guarantees that nothing remains under that delegation, but only after dedication and emptiness have been proved. Before that point, its simplicity is just unpriced risk.

Stick with record-level retirement for shared corporate zones, delegated subdomains used by several services, or any zone whose ownership evidence is incomplete. Choose whole-zone removal only for an isolated tenant zone after sending has stopped, queues have drained according to observed system behavior, owned records have been removed, the remaining inventory is empty, caches have had time to age, and a distinct approval is present.

That decision rule is less clever than a vendor-specific automation recipe. Good. The control plane may change; the ownership boundary should survive it.

References

Top comments (0)