Pointing a property management company's mail at a new provider should cost one DNS read when nothing has changed, and one read, one write, and one readback when it has. The least complex safe design is an idempotent record writer that compares normalized MX RRsets, skips a no-op, then verifies the published result. Keep customer-owned zones behind an approval boundary; automate platform-owned zones only where your team is authoritative.
Short answer: treat the whole MX RRset as the unit of change, compare it with the desired RRset before writing, and accept success only after a readback matches.
That call budget matters. A nightly reconciliation across 8,000 properties produces 8,000 reads when every zone is already correct; blindly writing and checking would produce 24,000 operations. This is arithmetic for the control loop, not a claim about any provider's billing. Storage has a similar shape: normalized before-and-after RRsets and a request identifier are compact, while full response bodies, resolver traces, and repeated no-op snapshots grow with every run.
What does the operation actually cost?
Count remote operations before choosing a client library. A no-op needs one read. A real change needs three operations: read, write, read back. A conflict needs a fresh read before any retry because the observed state has changed. Retries therefore belong around a newly evaluated transaction, not around a stale write payload.
| Observed state | Action | Remote operations | Evidence to retain |
|---|---|---|---|
| Equal after normalization | Skip | 1 | Compared RRset and outcome |
| Different, version unchanged | Replace and read back | 3 | Before, approval, and readback |
| Different, version changed | Stop and re-plan | At least 2 | Both observed versions |
For a company managing mail for thousands of buildings, no-op reconciliation is likely to dominate after migration day. I'm not sure it will dominate your workload; the deciding evidence is the ratio of unchanged checks to approved changes in your own audit log. Instrument dns_change_outcomes_total with noop, changed, conflict, and verification_failed outcomes, then inspect that ratio before tuning retention or polling.
Retain the desired RRset, the observed RRset used for comparison, the readback RRset, zone ownership, actor, timestamps, and a correlation ID for each attempted change. Don't retain identical raw provider responses forever. After the incident-response and compliance window your organization has chosen, compact repeated no-ops into counts and keep change transactions longer. The catch is reduced forensic detail: if a resolver or upstream API later changes its response shape, compacted events won't preserve every header or incidental field.
How should a safe DNS record writer compare, skip no-ops, and read back?
Compare canonical data, not response formatting. For MX records, the meaningful value in this control loop is a set of preference-and-exchange pairs. Lowercase exchange names, remove a trailing dot for comparison, reject malformed values before the first remote call, sort the pairs, and preserve TTL as an explicit policy choice. Do not flatten the RRset to one record: multiple MX values can be intentional, and replacing only one member can leave a mixed destination set.
The adapter below is deliberately generic. It defines the contract a provider-specific implementation must satisfy without inventing a commercial API route. The version token represents whatever concurrency primitive the backing system exposes; if none exists, the adapter can re-read immediately before applying and reject a changed snapshot.
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True, order=True)
class MxValue:
preference: int
exchange: str
@dataclass(frozen=True)
class MxSet:
values: tuple[MxValue, ...]
ttl: int
version: str
class ZoneStore(Protocol):
def read_mx(self, zone: str, name: str) -> MxSet: ...
def replace_mx(
self, zone: str, name: str, values: tuple[MxValue, ...],
ttl: int, expected_version: str
) -> None: ...
def normalize(values: tuple[MxValue, ...]) -> tuple[MxValue, ...]:
cleaned = {
MxValue(value.preference, value.exchange.rstrip(".").lower())
for value in values
}
if any(not value.exchange or value.preference < 0 for value in cleaned):
raise ValueError("invalid MX value")
return tuple(sorted(cleaned))
def ensure_mx(
store: ZoneStore, zone: str, name: str,
desired_values: tuple[MxValue, ...], desired_ttl: int
) -> str:
desired = normalize(desired_values)
before = store.read_mx(zone, name)
if normalize(before.values) == desired and before.ttl == desired_ttl:
return "noop"
store.replace_mx(
zone, name, desired, desired_ttl, expected_version=before.version
)
after = store.read_mx(zone, name)
if normalize(after.values) != desired or after.ttl != desired_ttl:
raise RuntimeError("MX readback did not match the approved change")
return "changed"
One detail is easy to miss — normalization belongs on both reads. If the desired exchange is mx1.mail.example and a response renders it as MX1.MAIL.EXAMPLE., a string comparison creates a false change. The writer should still log the provider's original representation as evidence, but it should compare the canonical tuple.
Keep conflict handling outside ensure_mx. The caller should report a conflict, fetch the new RRset, rerun policy checks, and require renewed approval when a customer-owned zone changed underneath the job. An automatic blind retry can overwrite a property administrator's emergency edit.
Stop there.
Put zone ownership ahead of automation
Customer-owned and platform-owned zones need different authorization even when they share the same comparison code. In a customer-owned zone, the customer controls the delegation and may use the apex for web, mail, verification, and policy records that your system must leave alone. Generate a proposed MX RRset, show the before-and-after values, record approval, and scope the adapter so it can replace only the approved owner name and type.
In a platform-owned zone, an internal controller can apply an approved change directly because the platform operates the zone. It still needs optimistic concurrency and readback. Ownership removes a coordination step; it does not make stale state harmless.
The safer default for a property management platform is customer ownership when the domain is the management company's corporate identity. Platform ownership fits a delegated subdomain or a domain registered specifically for managed communications. It is not suitable when legal, security, or IT teams require direct custody of the corporate zone. Conversely, stick with platform ownership for a dedicated sending domain when centralized controls and fast rollback matter more than customer-side editing.
Mail authentication is adjacent to the MX change, but it is not the same transaction. DMARC policies are published in DNS and can request aggregate and failure reporting, as RFC 7489 specifies. A narrow MX writer must not rewrite DMARC, SPF, DKIM, or unrelated verification records. Give each record family its own desired state and approval scope so a mail-routing cutover cannot silently alter an authentication policy.
Treat deployment and rollback as state transitions
Dry-run output should contain the normalized current RRset, normalized desired RRset, TTL decision, ownership class, and planned action. Feed that exact plan to the approved write rather than rebuilding it from form fields. At execution time, the version token prevents a stale approval from becoming a new overwrite. Readback verifies the control-plane state returned after the write; it does not prove that every recursive cache has expired or that mail delivery succeeds. Test those concerns separately. Query the authoritative view through your adapter, observe recursive resolution from the networks your runbook names, and send a controlled message through the new route. Delivery checks should record SMTP disposition and authentication results without putting recipient addresses or message bodies into broad operational logs. Compliance is part of the design, not cleanup work. Rollback is another compared write: store the complete previous MX RRset and TTL, read the current set, require it to match the value installed by this change, restore the previous set with the current version token, and read back again. If the current set differs, stop for review; another actor has changed the zone, so the old snapshot is no longer automatically safe to apply.
Be strict here.
A deployment pipeline can exercise the pure normalization and planning code with table-driven tests, then run adapter contract tests against an isolated zone. Include duplicate values, case and trailing-dot differences, reordered MX pairs, a TTL-only change, an empty desired set, a concurrent edit, and a readback mismatch. None of these tests needs access to a production customer domain.
What evidence should survive the retention window?
Keep enough evidence to answer three questions: who approved the change, what state the writer actually compared, and what the authoritative control plane returned afterward. A concise change record can hold hashes for raw payloads while an access-controlled archive holds the payloads themselves for the chosen retention window. Redact credentials completely; a token has no forensic value worth the exposure.
After that window, deliberately discard raw no-op bodies, repeated resolver traces, and message-level delivery samples unless a legal hold or incident requires them. Keep aggregate outcome counts and durable change records under the policy your organization has approved. You give up the ability to reconstruct every presentation-level difference months later, but you keep the evidence needed to explain an authorized MX transition without turning DNS reconciliation into an indefinite store of operational exhaust.
The decision rule is plain: automate only inside a declared ownership boundary, make equality explicit, and regard readback as part of the write. Everything else — library choice, queue choice, and polling interval — comes after that contract.
Top comments (0)