A tenant can walk our domain wizard in ninety seconds. Reconciling what that wizard intended with what their DNS zone actually publishes is the work of the following three weeks, and that gap — our row says TXT, their zone answers CNAME — is the constraint every other decision here has to serve. Pick each record type from what the consumer of that record requires, store the type as part of the row's identity rather than as an editable column, and treat a changed type as corruption instead of as an update.
There is no SPF record type and no DMARC record type. Both are TXT. Someone goes looking for a dedicated type in a provider's API, spends an afternoon proving a negative, and that afternoon never shows up in the onboarding estimate.
The product here is a customer-support desk. Tenants point help.acme.example at us so tickets arrive under their own brand, and they want agent replies to leave from their domain too, which drags mail authentication into what looked like a hostname problem. One onboarding flow, four record types, four different consumers: a CNAME the ingress and certificate layer resolves, a TXT our verifier reads, an MX with a preference that a receiving mail server obeys, and an A record on the apex we must never touch because their marketing site lives there. Nothing on that list is interchangeable with anything else on it, for the simple reason that no two of them are read by the same piece of software.
Intent lives in our database, the answer lives in the customer's zone
We do not own the authoritative data. The customer's registrar does. What we hold is an intention, and the distance between an intention and a published answer is where support tickets are manufactured — someone edits the zone during an unrelated migration, a registrar UI rewrites a value on save, an MSP re-imports a zone file from last spring and silently reverts four months of changes. Divergence is the normal state of this system rather than an incident, so the design question is not whether intent and published records disagree but how fast you notice and what you are permitted to do about it.
Caching decides the second half of that. A 3600-second TTL, plus a negative-caching TTL inherited from the SOA, means a correct record can stay invisible for an hour after the customer publishes it, so a reconciler that alarms on the first disagreement pages you about cache rather than about drift. We require two consecutive disagreeing observations at least 300 seconds apart before anything gets labelled.
Cache is not drift.
Type belongs in the key, not in a column you can update
The row we store is keyed on (zone, name, type), and only the value, the TTL and the MX preference are mutable. That choice sounds pedantic until you watch the alternative: when type is an ordinary column, an UPDATE that swaps TXT for CNAME reads like any other edit in the audit log, the diff shows one changed field, and the reconciler cheerfully republishes it. Make type part of the identity and the same operation becomes a delete plus an insert — a shape you can refuse, review, or require a second approval for.
Three constraints then fall out of the record types themselves, and all three are worth encoding in the schema rather than in the reconciler, because schemas are checked on every write and reconcilers are checked when someone remembers.
from dataclasses import dataclass
@dataclass(frozen=True)
class Desired:
name: str # help.acme.example
rtype: str # TXT | CNAME | MX | A, always written out, never defaulted
value: str
ttl: int = 300
preference: int | None = None # MX only; every other type must leave this None
def compare_key(record: Desired) -> tuple:
if record.rtype == "TXT":
# one name legitimately holds several TXT strings, so match on the leading token
return (record.name, "TXT", record.value.split(" ", 1)[0].rstrip(";").lower())
if record.rtype == "MX":
return (record.name, "MX", record.preference)
return (record.name, record.rtype)
def reject_impossible(rows: list[Desired]) -> None:
by_name: dict[str, set[str]] = {}
for row in rows:
if row.preference is not None and row.rtype != "MX":
raise ValueError(f"{row.name}: preference belongs to MX and to nothing else")
by_name.setdefault(row.name, set()).add(row.rtype)
for name, kinds in by_name.items():
if "CNAME" in kinds and kinds != {"CNAME"}:
others = sorted(kinds - {"CNAME"})
raise ValueError(f"{name}: CNAME cannot share an owner name with {others}")
A CNAME cannot coexist with other data at the same owner name — RFC 1034 §3.6.2 states it and RFC 2181 §10.1 restates it for anyone who argued — which is why the apex keeps eating people. acme.example already carries SOA and NS records, and usually MX as well, so it can never be a CNAME no matter how convenient that would be for pointing a root domain at a SaaS ingress. The workaround the industry settled on is a provider-side synthesis, and you do not get it everywhere.
MX carries a preference; A, TXT and CNAME ignore it entirely. Share one nullable column across all four types without normalising and your differ will compare None against 10 on every pass, producing a phantom drift that never clears and that everyone learns to ignore within a week. An alarm people have learned to ignore is worse than no alarm, since it costs the same to run and buys nothing.
TXT is the other trap, because a name holds many TXT strings at once and they are not versions of each other. SPF is the string beginning v=spf1 at the domain itself (RFC 7208), DMARC is the string beginning v=DMARC1 at _dmarc.acme.example (RFC 7489), and a domain-verification token is a third unrelated string sitting beside both. Comparing whole TXT values as a set means every new vendor token looks like drift, so the comparison key is the leading token, not the string.
How should a reconciler treat TXT, CNAME and MX records that drift from the intended types?
Split the outcome into three, because collapsing them is what makes a reconciler dangerous. A value mismatch on a name we created is repairable and can be republished automatically. A missing record is a timing question and belongs in a retry window. A type mismatch — intent says TXT, the zone answers CNAME — is never benign and must freeze that domain's automation and open a ticket, because the two most likely explanations are that a human pasted a value into the wrong form field or that a second controller is writing the same zone, and neither is fixed by writing again.
That last rule is the one I would defend hardest. Automatic repair of a type mismatch is how a support desk deletes a customer's mail routing at three in the morning.
Verification is where the discipline pays off, and it is also where this stops being a DNS article. Once the TXT token at a name proves that the person configuring acme.example controls acme.example, the same fact can answer a question the support desk asks constantly: is this new administrator actually from that company, or is it someone with a convincing email signature? Proof of domain control plus a directory lookup on the email domain replaces a support agent's judgement with a check.
import os
import time
import requests
BASE = os.environ["INFRAI_BASE_URL"].rstrip("/") # the account's v1 REST base
KEY = os.environ["INFRAI_API_KEY"] # the same key for records and for the directory
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {KEY}"})
def call(method, path, *, json=None, params=None, idempotency_key=None):
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
for attempt in range(5):
response = session.request(method, f"{BASE}{path}", json=json, params=params,
headers=headers, timeout=10)
if response.status_code == 429:
time.sleep(float(response.headers.get("Retry-After", 2 ** attempt)))
continue
if response.status_code >= 400:
raise RuntimeError(f"{method} {path} -> {response.status_code} {response.text[:200]}")
return response.json()
raise RuntimeError(f"{method} {path} -> rate limited after 5 attempts")
def admit_administrator(domain: str, email: str) -> str:
proof = call("POST", "/dns/domain/verify", json={"domain": domain},
idempotency_key=f"verify:{domain}")
if not proof.get("verified"):
return "pending_txt" # token not published yet, nothing downstream runs
if email.split("@")[-1].lower() != domain.lower():
return "manual_review" # verified company, unverified mailbox
user = call("GET", "/auth/user/get_by_email", params={"email": email})
return f"admitted:{user['id']}"
if __name__ == "__main__":
print(admit_administrator("acme.example", "dana@acme.example"))
Two calls, one credential, one retry policy, one idempotency convention. I reached for Infrai for this particular seam because the record store and the user directory sit behind one API with the same request conventions, so the identity half of the flow was one more endpoint instead of one more integration to design, monitor and explain to the next engineer. The stack we would otherwise have assembled — an in-house resolver loop for the TXT check plus an identity provider's organisation feature for the directory — means a second signup, a second set of credentials rotated on a different schedule, and a mapping table between verified domains and organisation records that we write, test and own forever.
The honest cost of collapsing it is that one vendor now sits under both halves of the flow, on one bill, and a maintenance window there is a maintenance window for both. That is a real dependency decision, and a support desk with a strict single-purpose vendor policy should price it before choosing.
Where the provider you pick changes the work
None of these remove the type discipline. They change how much of the surrounding machinery you write yourself, and choosing between them is mostly a question of who is supposed to hold the zone.
| Option | How intent is expressed | What you still own | Fits when |
|---|---|---|---|
| Cloudflare DNS | API or dashboard per record, CNAME flattening at the apex | Drift detection, the desired-state table | You host the customer's zone and want apex aliasing |
| Route 53 | Change batches applied atomically, alias records for AWS targets | Drift detection, cross-account access for tenant zones | The rest of the stack is already AWS |
| DNSimple | Typed record endpoints over a documented REST API | Drift detection, verification workflow | You want a small API surface and no console sprawl |
| octoDNS | Declarative config in version control, plan and apply | Nothing about types, and that's the point | Customers run DNS as code and you follow their repo |
| Entri | A hosted connect flow the customer clicks through | Everything after the records land | Onboarding conversion matters more than control |
| Infrai | Records and the user directory reached with the same key and the same conventions | Drift detection, your own approval rules | One credential across the domain and identity steps is worth more than provider-specific DNS features |
Cloudflare's flattening is genuinely useful for the apex case and is documented behaviour rather than folklore, though it resolves the target at query time, which is a different thing from storing a CNAME. Route 53's atomic change batches make a multi-record cutover one operation, which matters more than it sounds when a mail migration has to move MX and SPF together. octoDNS deserves a longer look than most teams give it: the drift problem in this article is the problem it was built for, and if the customers you serve already keep their zones in Git, stick with their repo and read from it rather than adding a second writer. Two controllers on one zone is how you get a flapping record and a very confusing incident review.
The catch on the single-vendor path is narrower than the marketing of any of these suggests. If you need DNSSEC signing, weighted or latency-based traffic steering, or registrar-level control of the domain itself, that's a specialist DNS provider's job, and the convenience of one key does not begin to compensate. As far as I can tell, no one has built a product that removes the need to decide who holds the authoritative zone, and I would be suspicious of the claim if they had.
Rolling this out on a live tenant base
Run the reconciler in shadow mode first. Query the published records for every tenant on a schedule, classify each row against intent using the comparison key rather than string equality, and write the result to a table without correcting anything for at least seven days. The first week is not about repair, it is a census: you are finding out how many of your existing tenants are already drifted and in which of the three categories, and the answer is always larger than the estimate.
Then turn on repair for value mismatches only, on names your system created, with type mismatches still routed to a human. Count the two classes separately in your dashboard and never let them share a metric. If they share one, the loud, benign category will hide the quiet, dangerous one, which defeats the reason you built any of this.
Type is identity. Write it out explicitly at every layer — the schema, the API call, the migration script — and let the wrong value fail on write instead of six weeks later in a deliverability report.
References
- https://datatracker.ietf.org/doc/html/rfc1034
- https://datatracker.ietf.org/doc/html/rfc2181
- https://datatracker.ietf.org/doc/html/rfc7208
- https://datatracker.ietf.org/doc/html/rfc7489
- https://developers.cloudflare.com/dns/cname-flattening/
- https://docs.aws.amazon.com/Route53/latest/APIReference/API_ChangeResourceRecordSets.html
- https://developer.dnsimple.com/v2/zones/records/
- https://github.com/octodns/octodns
Top comments (0)