DEV Community

LangstonHughes2689
LangstonHughes2689

Posted on

Property Mail DNS Writer — Read, Compare, Write, Then Verify

Approach Drift handling Mutation risk Best fit
Read, normalize, compare, write, read back Detects preexisting and post-write drift Lowest of these choices Automated SPF, DKIM, and DMARC publishing
Read and emit a plan Detects preexisting drift No automatic mutation Shared zones and approval-heavy teams
Blind upsert Overwrites observed state Highest Controlled, disposable zones

TL;DR: choose the guarded reconciliation loop for property-management mail domains. Treat declared SPF, DKIM, and DMARC values as intent, compare them with the current record set, skip exact semantic matches, mutate only from an expected state, then read back. The plan-only runner-up is slower but better when another team owns the zone.

The decision rule is blunt: optimize for explainable drift, not fewer lines of SDK glue. A writer that returns changed: false on a no-op is easier to benchmark, retry, and audit than one that sends an upsert every run.

How should a safe DNS record writer read and compare?

Property portfolios create awkward ownership boundaries. A leasing system may declare the mail records, while an IT team, an acquisition crew, or a registrar workflow can change the published zone. The dangerous gap is between those two states. A blind write erases the evidence that they diverged.

DMARC makes this operationally important. RFC 7489 defines a DNS-published policy record and describes reporting that gives domain owners visibility into authentication results. It also says receivers apply DMARC policy to messages that use the domain in the RFC5322.From field. The writer should therefore preserve record identity and surface disagreement; it should not guess that a different published value is disposable.

No magic here.

The safe default is optimistic concurrency at the adapter boundary: read a snapshot, compare a canonical form, and permit mutation only if the snapshot still matches the expected state. If the DNS API cannot express a conditional update, perform a second pre-write read and abort on change. That means 2 reads before a mutation and 1 after it in the fallback path, so it adds latency and query volume. The trade-off is deliberate: an invisible overwrite becomes an explicit conflict, and a successful response is separated from verified state.

Criterion one: compare meaning, not presentation

Raw string equality is cheap and often wrong. TXT data may arrive as several character strings even though the application cares about their concatenated value. Record ordering can also be irrelevant to the desired set. Keep provider response shapes out of the comparison core; normalize them into a tiny domain model first.

The normalization contract must stay narrow. Preserve the owner name, record type, TTL policy, and complete TXT value. Lowercase names used as DNS names, remove one trailing dot consistently, join TXT chunks without inventing spaces, and sort record sets only where order has no meaning. Do not reorder tokens inside an SPF or DMARC value. That would change the published text instead of normalizing its container.

I would benchmark this boundary with 3 fixtures: an exact match, a chunked TXT value, and a true conflict. The useful metric is not raw function throughput. It is the number of network mutations produced by an unchanged portfolio, which should be 0.

A good CLI result also needs to make every result inspectable without dumping a provider payload. Four outcomes cover the useful state machine: noop, planned, changed, and conflict. Each result should carry the normalized before and desired states; a successful mutation should also carry the read-back state. This is a small interface on purpose. Extra intermediate statuses leak adapter behavior into every CLI and SDK consumer, which is exactly the kind of glue that becomes permanent once users script against it.

That shape pays off under retries. A timeout after a write is ambiguous, so retry by reading again. If the desired state is now published, return a no-op-equivalent success and retain the evidence. If another value appears, return a conflict. Do not keep writing until the API stops objecting.

TTL deserves separate treatment from TXT content. A content match with a TTL mismatch may be acceptable under one policy and a required change under another. Put that choice in intent. Hidden defaults are config bloat wearing a friendly hat.

A compact TypeScript reconciler

The adapter below is deliberately generic. Its three methods are the only glue required from a DNS integration, and the compare function stays testable without network access.

type TxtRecord = {
  name: string;
  type: "TXT";
  ttl: number;
  value: string;
};

type Result =
  | { status: "noop"; before: TxtRecord }
  | { status: "planned"; before: TxtRecord | null; desired: TxtRecord }
  | { status: "changed"; before: TxtRecord | null; after: TxtRecord }
  | { status: "conflict"; expected: TxtRecord | null; actual: TxtRecord | null };

interface DnsAdapter {
  readTxt(name: string): Promise<TxtRecord | null>;
  replaceTxt(expected: TxtRecord | null, desired: TxtRecord): Promise<boolean>;
}

const normalize = (record: TxtRecord): TxtRecord => ({
  ...record,
  name: record.name.toLowerCase().replace(/\.$/, ""),
});

const same = (left: TxtRecord | null, right: TxtRecord | null): boolean =>
  JSON.stringify(left && normalize(left)) ===
  JSON.stringify(right && normalize(right));

async function reconcileTxt(
  dns: DnsAdapter,
  desiredInput: TxtRecord,
  apply: boolean,
): Promise<Result> {
  const desired = normalize(desiredInput);
  const beforeRaw = await dns.readTxt(desired.name);
  const before = beforeRaw && normalize(beforeRaw);

  if (same(before, desired)) return { status: "noop", before: desired };
  if (!apply) return { status: "planned", before, desired };

  const accepted = await dns.replaceTxt(before, desired);
  if (!accepted) {
    const actual = await dns.readTxt(desired.name);
    return { status: "conflict", expected: before, actual };
  }

  const afterRaw = await dns.readTxt(desired.name);
  const after = afterRaw && normalize(afterRaw);
  if (!same(after, desired)) {
    return { status: "conflict", expected: desired, actual: after };
  }

  return { status: "changed", before, after: desired };
}
Enter fullscreen mode Exit fullscreen mode

The important line is not the replace call. It is the final read. An accepted mutation means the control plane accepted a request; the read-back proves what the same adapter now observes. Keep that distinction in logs. For wider DNS observation, query the authoritative service through a separate probe, because a provider read and a public DNS lookup answer different operational questions.

For a portfolio run, cap concurrency and report per-domain results rather than failing the whole batch at the first conflict. SPF, DKIM, and DMARC records should each be distinct intent items. One conflict must not conceal the status of the other two.

When the plan-only runner-up is better

Choose read-and-plan when zone ownership sits outside the application team, a human approval is required, or the available adapter cannot reject a stale expected value. The plan artifact should contain the normalized before and desired records plus a stable intent identifier. The approver can then detect if the plan itself went stale before applying it. This is a real limitation of the automated reconciler: it is not suitable when the adapter cannot provide a trustworthy current read or protect against a stale write. Use the plan and an owner-controlled change process instead.

This path costs another handoff, so time-to-first-published-record is worse. I would still take it over a blind upsert for an acquired property domain or a shared corporate zone.

Slower, but honest.

The trade-off is explicit: slower delivery for a smaller mutation boundary. The guarded writer has its own cost too. Repeated reads add latency, consume API quota, and still cannot prove what every recursive resolver currently caches. Read-back verifies the observation surface you selected; it does not turn DNS propagation into a transaction.

Blind upsert has one defensible niche: disposable zones whose complete contents are generated and exclusively owned by the same process. Even there, read-back remains useful. For production property mail, ownership is rarely clean enough to assume that niche without evidence.

The final acceptance rule is simple: unchanged intent produces no write; changed intent produces either a verified state or a visible conflict. Anything less hides drift, and hidden drift is exactly what this writer exists to remove.

References

Top comments (0)