TL;DR: Treat a logistics tenant's published MX records as quota evidence, and treat its tenant row as intent. Reserve capacity before a domain is configured, but enforce the steady-state limit from scheduled reconciliation. This prevents a stale application table from becoming the authority after DNS changes outside your app.
| Choice | Request latency | Detects outside edits | Best use |
|---|---|---|---|
| Trust the tenant table | Low | No | Temporary reservation |
| Resolve DNS on every request | Variable | Yes | Manual verification |
| Reserve, then reconcile | Low | Yes, after an interval | Ongoing enforcement |
Recommendation: reserve a slot synchronously, verify MX publication asynchronously, and reconcile every tenant on a fixed schedule. The first check prevents concurrent requests from claiming one slot. The second makes published DNS, rather than yesterday's database state, the durable evidence.
For a one-person SaaS, this is the useful trade-off: the request path stays small, while one generic worker owns the undifferentiated polling and retry work. It ships without making DNS latency part of every user action.
Why can't the tenant table be the quota ledger?
A logistics company may route mail for dispatch.example, returns.example, and several regional domains. Your database can say all three are active while one has no MX record. It can also say two are active after an administrator publishes a third record without using your UI. Neither condition requires a broken database. Intent and publication live in different systems.
A row such as status = 'active' records what the application last believed. An authoritative DNS answer records what the domain currently publishes. Recursive resolvers cache answers for their TTL, so even the DNS view is an observation with a timestamp, not an instantaneous global truth. Reconciliation must preserve that timestamp and tolerate a transition window.
Rows remember. DNS changes.
DMARC does not define MX quota semantics. RFC 7489 does illustrate an important mail-domain boundary: policy is discovered through DNS, whose data can change independently from your application. Keep authentication policy separate from the quota question. An MX record proves routing configuration; it does not prove that SPF, DKIM, or DMARC is correct.
That boundary matters.
Count a domain as published only when the observed MX set matches the tenant's declared target set under an exact, documented rule. Do not count any MX record as success. A record aimed at an old mail system is drift, not evidence that the requested configuration exists. Normalize hostnames by lowercasing them and removing a trailing dot before comparison. Preserve MX preference values because they are part of the record.
Use two ledgers and one explicit state machine
The two criteria that matter most are oversubscription control and recovery from drift. A database transaction can solve the first. Only a fresh external observation can solve the second. Combining them into one Boolean hides which guarantee you have.
Use four states: reserved, verified, drifted, and released. A new request creates a reservation inside the same transaction that checks the limit. The reconciler moves it to verified after matching DNS evidence, or to drifted after a mismatch. A deliberate removal moves it to released. Define reservation expiry as policy, not as an accidental worker timeout; until expiry, a reservation consumes capacity so parallel requests cannot race past the quota.
Consider a tenant with a quota of three. dispatch.example and returns.example are verified, while east.example is reserved during setup. The database transaction must reject a fourth reservation even though DNS currently shows only two matches. Later, the worker sees that east.example matches and marks it verified; the count remains three. If an administrator then redirects returns.example to an unexpected exchanger, that claim becomes drifted but the count still remains three. The tenant can repair or explicitly release it, but cannot use drift to open a hidden fourth slot. If the lookup for returns.example merely times out, the worker records an error and keeps the previous state. These four events look similar in a dashboard unless the observation and intent are stored separately. Their quota consequences are different, which is precisely why one active flag is too weak.
Now the count is defensible.
type DomainState = "reserved" | "verified" | "drifted" | "released";
interface DomainClaim {
tenantId: string;
domain: string;
state: DomainState;
reservationExpiresAt?: Date;
}
function consumesQuota(claim: DomainClaim, now: Date): boolean {
if (claim.state === "verified" || claim.state === "drifted") return true;
if (claim.state !== "reserved") return false;
return claim.reservationExpiresAt === undefined || claim.reservationExpiresAt > now;
}
Drift still consumes quota. Otherwise, a tenant could publish a replacement, let the old entry become drifted, and retain both DNS configurations while consuming one slot. Release capacity only after an explicit removal or an expired reservation whose target was never verified.
Store observations separately from desired domains. An observation needs the normalized MX set, lookup time, outcome, and an error class. Keep the last successful answer when a later lookup times out. A timeout means unknown, not absent. NXDOMAIN and an empty MX result are also different evidence from a transport failure.
Implement the reconciler in Node.js
This example uses the built-in promise-based DNS resolver. The interfaces keep storage and scheduling out of the DNS logic. The worker compares an exact normalized MX set, records every attempt, and changes claim state only after a successful lookup.
import { resolveMx } from "node:dns/promises";
type ClaimState = "reserved" | "verified" | "drifted" | "released";
type Mx = { exchange: string; priority: number };
interface Claim {
id: string;
domain: string;
state: ClaimState;
expectedMx: Mx[];
}
interface Observation {
claimId: string;
checkedAt: Date;
outcome: "match" | "mismatch" | "lookup_error";
records: Mx[];
errorCode?: string;
}
interface Store {
dueClaims(limit: number): Promise<Claim[]>;
saveObservation(value: Observation): Promise<void>;
setStateIfCurrent(id: string, from: ClaimState, to: ClaimState): Promise<void>;
}
const normalize = ({ exchange, priority }: Mx): Mx => ({
exchange: exchange.toLowerCase().replace(/\.$/, ""),
priority,
});
const fingerprint = (records: Mx[]): string => records
.map(normalize)
.sort((a, b) => a.priority - b.priority || a.exchange.localeCompare(b.exchange))
.map(({ priority, exchange }) => `${priority} ${exchange}`)
.join("\n");
async function reconcileClaim(store: Store, claim: Claim): Promise<void> {
const checkedAt = new Date();
try {
const records = (await resolveMx(claim.domain)).map(normalize);
const match = fingerprint(records) === fingerprint(claim.expectedMx);
await store.saveObservation({
claimId: claim.id,
checkedAt,
outcome: match ? "match" : "mismatch",
records,
});
const next: ClaimState = match ? "verified" : "drifted";
await store.setStateIfCurrent(claim.id, claim.state, next);
} catch (error) {
const errorCode = typeof error === "object" && error !== null && "code" in error
? String(error.code)
: "UNKNOWN";
await store.saveObservation({
claimId: claim.id,
checkedAt,
outcome: "lookup_error",
records: [],
errorCode,
});
}
}
export async function reconcileBatch(store: Store): Promise<void> {
const claims = await store.dueClaims(50);
await Promise.allSettled(claims.map((claim) => reconcileClaim(store, claim)));
}
setStateIfCurrent is a compare-and-set operation. It prevents an older lookup from reviving a claim that a user released while the request was in flight. In SQL, its essential condition is WHERE id = ? AND state = ?; a zero-row update means the result became stale.
The batch size of 50 is an example control, not a universal throughput claim. Make it configuration. Start with bounded concurrency, randomize scheduling so every tenant is not queried on the minute, and tune from lookup duration and error metrics. Shipping weekly favors controls you can inspect over a clever queue topology.
Keep it dull.
For tests, inject the lookup function rather than patching global DNS. Cover reordered answers, case and trailing-dot normalization, changed priorities, empty answers, lookup errors, a concurrent release, and duplicate worker delivery. The fingerprint makes order irrelevant but keeps MX preference.
Deploy evidence, not a polling illusion
A worker that runs is not necessarily a reconciler that works. Track the age of the oldest due claim, time since each tenant's last successful observation, mismatch count, lookup errors by code, and compare-and-set misses. Alert on stale evidence first. A low error rate can look healthy while a scheduling bug leaves half the claims untouched.
Retries need restraint. Retry transient lookup failures with exponential backoff and jitter, but leave the claim's last verified or drifted state unchanged until a successful observation supplies new evidence. Cache the observation until its next scheduled check. Do not add a DNS call to every dashboard render; that turns resolver latency into user latency and multiplies queries without improving the model.
Roll out in shadow mode. First compute reconciled state without enforcing it, then inspect disagreement categories. Next, enforce new reservations while only alerting on existing drift. Finally, enable the documented release and expiry policies. This staged rollout exposes bad normalization or target data before those mistakes block a dispatch team from adding a mail domain.
There is a mail-specific trap: lower MX preference values are tried first, and multiple records may intentionally provide alternate exchangers. Compare the full expected set. Do not select one answer and call it done. Also keep quota decisions independent from DMARC evaluation; both use DNS, but they answer different questions.
When is the runner-up better?
Resolving DNS during a manual “verify now” action is the runner-up. It is better when a person has just changed records and needs immediate feedback, or when a high-risk administrative action requires evidence fresher than the background interval. Return a pending result on timeout rather than pretending the domain is absent. The scheduled reconciler must still revisit the claim because a browser request is not a durable scheduler.
Trusting only the tenant table is acceptable for a short-lived reservation before publication is expected. It is also useful for a preview that answers, “What will consume capacity if these changes are applied?” Label that view as intent. Do not present it as observed configuration.
The main limitation of reconciliation is delayed detection: drift can persist until the next successful lookup. It also costs a worker, an observation table, and operational attention. It is not suitable when policy demands proof at the exact instant of an administrative action; use an inline lookup there, then retain background reconciliation for recovery. Conversely, inline-only verification is a poor fit for continuous enforcement because no user request may arrive after an outside DNS edit.
No single read closes both gaps.
The operating rule stays compact: transactions protect the quota at write time; reconciliation protects it over time. DNS evidence can be stale, so record when it was seen. Application intent can be wrong, so never let a status column impersonate the network.
Top comments (0)