DEV Community

DorianVale91583
DorianVale91583

Posted on

Safe DNS Record Writer: Compare Before Mutation (for Player Domains)

Short answer: build a safe DNS record writer as an evidence-first transaction: read the current RRset, compare a normalized desired value, skip an equivalent state, write only on drift, then read back and emit proof.

A gaming platform has two DNS jobs: accepting a customer-owned zone and maintaining a platform-owned zone. Treating them as one write path is how a retry becomes a broken login. The mechanism can be shared, but the authorization cannot.

The mental model is a state machine: observed, desired, then verified. A customer-owned zone should stop at a plan when ownership evidence is missing. A platform-owned zone can proceed under a service identity.

No proof, no mutation.

What does “safe” mean for a game domain?

Safety means preserving fields you do not own, handling duplicate values deterministically, and making stale reads visible. The writer should be idempotent: running it twice produces one effective change and one no-op. Ownership is the first branch. For a platform-owned zone, store an allowlisted zone identifier and service principal. For a customer-owned zone, require verified delegation or challenge evidence and scope the mutation to the exact name and type. Never infer authority from a hostname pasted into a support ticket.

Picture a studio onboarding play.arcade.example. The requested CNAME target is already present, but its spelling has a trailing dot and the returned values arrive in a different order. A raw string comparison reports drift. A semantic comparison reports a no-op. Now picture the same request targeting arcade.example, where unrelated MX and TXT data already exist. Replacing the entire RRset payload without preserving the fields outside the writer's responsibility can damage mail authentication even though the game hostname looked correct. RFC 7489 places DMARC policy in DNS; that is a useful reminder that one zone often serves several independent systems.

The boundary must be explicit. A customer-owned zone needs proof that the customer authorized this exact scope, plus a plan the customer can inspect before mutation. A platform-owned zone can use an automated service identity, but that identity still should not receive blanket authority over every zone. This creates more policy code. It is worth it because the audit event can answer both questions after an incident: who was allowed to ask, and what state did the writer actually observe?

There is a trade-off. Extra reads increase latency and consume control-plane capacity, so this pattern is not suitable for a latency-sensitive request path that must answer a player synchronously. Put it in an asynchronous provisioning job with a bounded retry policy. DNS changes already involve caches and TTLs; a successful authoritative read-back proves control-plane state, not immediate visibility from every recursive resolver.

That distinction matters.

A small read-compare-write transaction in Node.js

The interface is generic enough for an authoritative API, a self-hosted control plane, or a test double. The order and audit payload matter.

type RecordValue = { name: string; type: string; ttl: number; values: string[] };
interface DnsStore { read(name: string, type: string): Promise<RecordValue | null>; write(record: RecordValue): Promise<void>; }
const normalize = (r: RecordValue | null) => r && ({ ...r, name: r.name.toLowerCase(), values: [...r.values].sort() });
export async function ensureRecord(store: DnsStore, desired: RecordValue) {
  const before = normalize(await store.read(desired.name, desired.type));
  const target = normalize(desired)!;
  if (JSON.stringify(before) === JSON.stringify(target)) return { status: "no-op", before, target };
  await store.write(target);
  const after = normalize(await store.read(target.name, target.type));
  if (JSON.stringify(after) !== JSON.stringify(target)) throw new Error("DNS verification failed");
  return { status: "changed", before, target, after };
}
Enter fullscreen mode Exit fullscreen mode

Normalization prevents harmless ordering differences from becoming writes. In production, use typed comparison for provider defaults and preserve unknown fields. Do not treat the compact JSON.stringify comparison above as a universal DNS equality algorithm: record types have different presentation rules, and strings that look similar may carry different meaning. Build canonicalizers per supported type, test them with fixtures, and reject types the writer does not understand.

A failed verification is a hard failure. Capture the response and retry through a controlled job. The retry should begin with another read, because the first attempt may have succeeded while its response was lost. If the state now matches, emit no-op-after-retry and stop. If it differs, compare the fresh state with the original before hash; a third-party change means conflict, not permission to overwrite.

The example also hides authentication and transport failure handling on purpose. Those belong in the adapter, where timeouts, rate limits, and expired credentials can be classified without weakening the state machine. Keep the core function small. Test four cases: already equal, successful change, write rejection, and read-back mismatch. Add a fifth for a concurrent update if the backing API offers conditional writes or version tokens.

The limitation is clear: read-compare-write is not an atomic transaction by itself. Another actor can write between either read and the mutation. Where conditional updates exist, attach the observed version. Where they do not, serialize changes per zone and treat any surprising read-back as a conflict requiring a new plan. More machinery, yes. Clear failure semantics are preferable to silent last-writer-wins behavior for player-facing names.

Stop on conflict.

Which signals make a failed change diagnosable?

Log one structured event per decision with a correlation ID, ownership class, record name and type, hashes of before and target values, decision, and latency. Do not log tokens or private challenge values. Include the attempt number so three retries do not look like three independent requests. Keep full record values in a protected audit store only when the retention and access policy permit it; hashes are usually enough for high-volume operational logs.

Metrics should separate read errors, write errors, and post-write mismatches. Count no-ops too. Alert on verification failures and an unusual rise in writes for one zone. A sudden drop in no-op rate can expose an unstable normalizer or a deployment that changes TTL values on every run, while a sudden increase in ownership rejections may point to an onboarding flow issuing requests before verification finishes. These are different failures and deserve different pages.

Use a correlation ID from plan through verification. Then the event sequence reads like a diagram in words: request accepted, ownership checked, state observed, comparison decided, mutation attempted, state verified. The same identifier should appear in the job record and each structured log event. A dashboard can show p50 and p95 completion latency by ownership class, but alerting should focus on outcomes rather than a single slow operation; one slow authoritative API call is interesting, while a growing queue of unverified changes threatens the launch workflow.

Read-back has another boundary. It confirms what the authoritative control plane reports, not what a player in another network resolves at that instant. If rollout validation needs the public view, schedule separate probes through independent recursive paths after the authoritative transaction completes. Do not fold those probes into the write success condition unless the product contract explicitly promises global observation, because caches may legitimately retain the prior answer until its TTL expires.

Two objections worth answering

“Why not write every time?” Retries are normal in queues and deploy hooks. Blind writes amplify transient failures, create noisy audit trails, and make it harder to distinguish actual drift from repeated intent. Read-compare turns a retry into an observable decision. The added read is a deliberate cost, so batch or rate-limit reconciliation jobs rather than removing the guard.

“What if the second read races with another operator?” The read-back exposes that conflict. Add an optimistic version or ownership lock where supported, and surface the mismatch for review. Do not claim success from the write response alone. A lock owned only by your application cannot coordinate with customer administrators, so customer-owned zones still need conflict detection and a fresh plan.

One more objection usually follows: “Why separate customer-owned and platform-owned zones if the code is identical?” Because identical mutation code does not imply identical authority. The platform can establish durable policy for its own zones; a customer can revoke delegation or change records outside your workflow. Keeping ownership class in the request, policy decision, metrics, and audit evidence makes that difference visible instead of burying it in credentials.

Customer-owned zones need stronger authorization evidence; platform-owned zones need tighter service boundaries. Both need read, compare, write, and read-back. That protects a game launch without making DNS an opaque deployment side effect.

Sources

Top comments (0)