An education platform should default to an idempotent DNS write, but only after it proves which record it owns. In an internal admin console, that means treating create, update, and upsert as different safety contracts rather than three interchangeable verbs. My default is an ownership-keyed upsert guarded by a read-back check; use create when duplicates must stop the job, and update when a stable record identifier is already under platform control.
The choice in one screen
| Operation | Best default | Failure signal | Evidence to retain |
|---|---|---|---|
| Create | First claim of a name/type pair | Existing record blocks provisioning | Pre-write absence and provider response |
| Update | A known record ID is platform-owned | ID is stale or points at another tenant | Before/after values and revision |
| Upsert | Repeated reconciliation of one owned key | An unowned value would be overwritten | Ownership proof, desired hash, read-back |
For a school tenant's _dmarc or SPF record, I would choose upsert only when the console has a precise ownership rule: zone, normalized name, record type, and a token in the value or metadata. Without that rule, upsert is a polite name for “overwrite whatever is there.” That is how a deliverability incident starts.
What should create, update, or upsert mean for idempotent Node.js provisioning?
Idempotency is not “the second request returns 200.” It is the stronger property that retrying the same intent converges on one published state without deleting a customer's unrelated policy. DNS makes this subtle because an RRset can contain several values, and providers disagree about whether an update replaces one value or the complete set.
The safe key is usually (zone, owner name, type), plus a selector for multi-value records. Normalize a trailing dot, lowercase case-insensitive names, and sort values before hashing. Store the intent hash with the tenant and change request. A retry can then distinguish “already applied” from “the zone drifted.”
Here is the small adapter I want behind the admin console. The endpoint is deliberately generic; the important part is the contract and the evidence, not a vendor SDK.
type DnsRecord = {
zone: string;
name: string;
type: "TXT" | "CNAME" | "MX";
values: string[];
ttl: number;
};
type Evidence = {
before: DnsRecord | null;
after: DnsRecord;
desiredHash: string;
ownershipToken: string;
};
async function reconcileRecord(
api: { get: (key: string) => Promise<DnsRecord | null>; upsert: (record: DnsRecord) => Promise<DnsRecord> },
desired: DnsRecord,
ownershipToken: string,
): Promise<Evidence> {
const key = `${desired.zone}|${desired.name.toLowerCase().replace(/\.$/, "")}|${desired.type}`;
const before = await api.get(key);
const owned = before?.values.some((value) => value.includes(ownershipToken)) ?? false;
if (before && !owned) {
throw new Error("record exists without an ownership proof");
}
const normalized = { ...desired, values: [...desired.values].sort() };
const after = await api.upsert(normalized);
const desiredHash = JSON.stringify(normalized);
return { before, after, desiredHash, ownershipToken };
}
The read-before-write is not a performance flourish. It gives the operator a reason for a refusal, which is much easier to audit than a blind replacement. I also persist the provider response and a resolver observation separately; an accepted write is not proof that public DNS has converged.
Evidence gates for deliverability changes
The first gate is authority. Confirm that the zone is delegated to the nameservers you expect, and record which tenant owns the change. A customer-owned zone deserves an explicit approval and a conflict path. A platform-owned zone can use a stricter policy, but it still needs a tenant boundary.
The second gate is syntax and policy. TXT strings must be encoded exactly as the authoritative server expects. For DMARC, the policy record is a published signal, not a guarantee that every mailbox will accept mail; RFC 7489 defines reporting and alignment semantics that the console should expose to operators. Validate SPF's single-record constraint before writing, and reject an attempted second policy instead of silently joining strings.
The third gate is observation. Query an authoritative server after the write, then query at least one recursive resolver on a schedule that matches the record's TTL. Save timestamps, nameserver identity, answer values, and the change ID. In an education launch, this evidence is more useful than a green button: support can show whether a failed verification is propagation, delegation, or an actual record mismatch.
Short checklists are good. A giant “DNS is healthy” boolean is not.
The common race is two console workers reading the same empty key and both issuing create. If create is your contract, the second response must be a conflict that triggers a fresh read, not an automatic second write. If you need convergence, use an upsert with an ownership token and an idempotency key attached to the change request. In practice, that race can involve a queue retry, a browser double-submit, and a scheduled reconciliation job all arriving within one TTL window. The logs then show three apparently valid intents, each with a different request ID, while the zone has one final RRset. A useful event record therefore carries the normalized key, the desired hash, the actor, and the prior observation. During review, you can answer which intent won and why, instead of guessing from the last HTTP status.
Measure twice.
Another trap is replacing an RRset when the intent was to add one verification value. Model additive and replacement operations separately. For TXT, preserve values you do not own; for CNAME, reject coexistence because the name cannot safely carry a competing target. These rules belong in the domain layer, before an HTTP client is called.
I once assumed a successful provider response was enough evidence. It was not. A response can confirm acceptance while delegation still points at a previous zone, so the console needs an authoritative read and a later recursive read before it reports deliverability readiness. Your mileage may vary with resolver geography, but the distinction is universal.
When the runner-up is the better choice
Upsert is a poor fit when the record is intentionally customer-managed, when the provider cannot express conditional writes, or when a legal/audit process requires an explicit diff approval. Stick with create for a one-time claim workflow, and make the conflict visible. Stick with update when the record ID is immutable, ownership is already proven, and replacing the complete RRset is the documented behavior.
The trade-off is extra state: intent hashes, ownership tokens, and observation logs. That is acceptable for an admin console that can affect student and faculty mail. It is not suitable for a throwaway preview environment where the zone is disposable; there, a mocked resolver and a dry-run plan are safer and faster.
The decision rule is simple: choose the least destructive operation that can converge, then require evidence that the published state matches the owned intent. The verb matters less than the boundary around it.
References
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
- RFC 1034, Domain Names — Concepts and Facilities: https://datatracker.ietf.org/doc/html/rfc1034
- RFC 1035, Domain Names — Implementation and Specification: https://datatracker.ietf.org/doc/html/rfc1035
- MDN, HTTP conditional requests and ETags: https://developer.mozilla.org/en-US/docs/Web/HTTP/Conditional_requests
Top comments (0)