TL;DR: Treat a DMARC rollout as a persisted state machine, not a calendar script. Before every scheduled transition, query DNS again, confirm that the customer still controls the expected record, validate the record you actually received, and advance by one enforcement step only. For a media platform onboarding customer-owned sending domains, that ownership check belongs on the critical path. A platform-owned zone can use the same state machine, but it has a different trust boundary and can usually be changed atomically by the platform.
The practical sequence is p=none, then sampled p=quarantine, broader quarantine, and finally p=reject. Reports drive the decision to schedule a transition; fresh DNS evidence authorizes the transition. Time alone does neither.
How should a DMARC policy progress through scheduled stages?
A schedule answers when a job may run. It cannot answer whether the domain is still under the same control, whether a DNS delegation changed, or whether the published DMARC record matches the rollout state stored by the application. Those are separate facts. Conflating them turns a harmless delayed job into authority to modify another party's mail policy.
A timer proves nothing.
This distinction matters in media onboarding because a publication may bring its own domain while the platform operates the sending pipeline. The publication owns the zone. The platform owns its workflow record. Neither database state nor an earlier successful DNS check makes the platform the current DNS authority.
For a platform-owned zone, the control plane is tighter: the service that advances the rollout can write the record and read it back. Customer-owned zones need a handoff. The application proposes the next record, the customer publishes it, and the application observes DNS before marking that stage active. I keep those paths explicit because outsourcing an undifferentiated DNS write is sensible; pretending the ownership boundary disappeared is not.
DMARC adds another reason to be careful. A receiver discovers policy at a DNS TXT record under _dmarc. The p tag expresses the requested treatment for mail that fails DMARC, and pct can limit the percentage of messages to which the requested policy is applied. none, quarantine, and reject are not increasing numeric values in an API. They carry operational meaning.
The smallest rollout I would ship has four stages:
| Stage | Published policy | Purpose | Advance only after |
|---|---|---|---|
| Observe | p=none |
Collect reports without requesting enforcement | Ownership is fresh and report review is acceptable |
| Sample | p=quarantine; pct=25 |
Limit initial enforcement exposure | Ownership is fresh and sampled results are acceptable |
| Quarantine | p=quarantine; pct=100 |
Apply quarantine policy broadly | Ownership is fresh and broad results are acceptable |
| Reject | p=reject; pct=100 |
Request rejection for failing mail | Ownership is fresh and the operator approves the final step |
That table is a policy, not a claim that every domain should wait the same number of days. RFC 7489 recommends a slow rollout and the use of reporting, but the acceptable evidence and timing depend on the domain's legitimate mail sources. A weekly shipping cadence is useful for reviewing changes. It is not a substitute for evidence.
The constraint that changed the design
The tempting implementation stores nextRunAt, wakes up, increments a stage index, and publishes the next TXT value. It is compact. It is also missing the one check that gives the workflow authority to proceed.
So the scheduled job should be split into observation and mutation. Observation resolves the ownership token and current DMARC record. Mutation happens only after the observation matches the expected domain, token, and current stage. If it does not match, the job records a blocked result and stops. No automatic repair.
Stop means stop.
This is also where customer-owned and platform-owned zones should diverge. For a customer-owned zone, the job emits a proposed record and waits for external publication. For a platform-owned zone, it may call an internal DNS adapter, then re-read the record before committing the new active stage. The rollout rules remain shared, while the write authority does not.
I would store the normalized domain, ownership token hash, active stage, proposed stage, earliest transition time, last verification time, and a monotonic revision. The revision prevents two workers from advancing the same rollout concurrently. The verification result should include the observed TXT values and resolver timestamp so an operator can explain why a transition was blocked without reconstructing the event from application logs.
There are two independent gates:
- The domain proves current ownership with an application-specific TXT challenge.
- The current DMARC record equals the record expected for the active stage.
That gives this first build 4 states, 2 gates, and 1 allowed direction: forward by a single step. I chose that constraint because a compact state machine is easier to audit between weekly releases. The trade-off is deliberate friction when a customer changes DNS outside the onboarding flow.
The ownership challenge is deliberately separate from _dmarc. A valid DMARC policy proves that somebody published a policy; it does not prove that the party is the customer currently completing this onboarding workflow. Bind a random, single-purpose challenge to the account and normalized domain, expire it, and consume it according to the application's threat model. The exact token construction is application security work, not a DMARC feature.
The smallest working TypeScript implementation
The core can stay small if DNS access, persistence, and zone writes sit behind interfaces. That makes the transition testable without teaching business logic about a particular DNS provider. It also keeps the revenue-per-hour calculation sane: spend custom engineering time on authorization and failure handling, not on a proprietary SDK wrapper.
import { Resolver } from "node:dns/promises";
type Stage = "observe" | "sample" | "quarantine" | "reject";
type Rollout = {
id: string;
domain: string;
ownershipToken: string;
stage: Stage;
revision: number;
nextRunAt: Date;
zoneOwner: "customer" | "platform";
};
interface Store {
loadDue(id: string, now: Date): Promise<Rollout | null>;
block(id: string, revision: number, reason: string): Promise<void>;
propose(id: string, revision: number, next: Stage, value: string): Promise<void>;
commit(id: string, revision: number, next: Stage, checkedAt: Date): Promise<void>;
}
interface ZoneWriter {
replaceDmarc(domain: string, value: string): Promise<void>;
}
const records: Record<Stage, string> = {
observe: "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.invalid",
sample: "v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc-reports@example.invalid",
quarantine: "v=DMARC1; p=quarantine; pct=100; rua=mailto:dmarc-reports@example.invalid",
reject: "v=DMARC1; p=reject; pct=100; rua=mailto:dmarc-reports@example.invalid",
};
const nextStage: Partial<Record<Stage, Stage>> = {
observe: "sample",
sample: "quarantine",
quarantine: "reject",
};
function normalizeTxt(rows: string[][]): string[] {
return rows.map((chunks) => chunks.join("").trim());
}
async function verify(resolver: Resolver, rollout: Rollout) {
const [ownershipRows, dmarcRows] = await Promise.all([
resolver.resolveTxt(`_media-ownership.${rollout.domain}`),
resolver.resolveTxt(`_dmarc.${rollout.domain}`),
]);
const ownership = normalizeTxt(ownershipRows);
const dmarc = normalizeTxt(dmarcRows);
return {
ownershipMatches: ownership.includes(
`media-verification=${rollout.ownershipToken}`,
),
dmarcMatches: dmarc.length === 1 && dmarc[0] === records[rollout.stage],
observedAt: new Date(),
};
}
export async function advance(
id: string,
now: Date,
store: Store,
writer: ZoneWriter,
resolver = new Resolver(),
): Promise<void> {
const rollout = await store.loadDue(id, now);
if (!rollout) return;
const next = nextStage[rollout.stage];
if (!next) return;
let check;
try {
check = await verify(resolver, rollout);
} catch (error) {
await store.block(rollout.id, rollout.revision, `dns-check-failed: ${String(error)}`);
return;
}
if (!check.ownershipMatches || !check.dmarcMatches) {
await store.block(rollout.id, rollout.revision, "fresh-dns-evidence-did-not-match");
return;
}
const value = records[next];
if (rollout.zoneOwner === "customer") {
await store.propose(rollout.id, rollout.revision, next, value);
return;
}
await writer.replaceDmarc(rollout.domain, value);
const afterWrite = await verify(resolver, { ...rollout, stage: next });
if (!afterWrite.ownershipMatches || !afterWrite.dmarcMatches) {
await store.block(rollout.id, rollout.revision, "published-record-not-yet-observed");
return;
}
await store.commit(rollout.id, rollout.revision, next, afterWrite.observedAt);
}
example.invalid is reserved for documentation, so production code must supply a real reporting mailbox authorized for the domain. The string equality check is intentionally strict for this minimal build because the application generated the expected record. A mature parser can accept harmless tag ordering and whitespace differences, but it must still reject multiple DMARC records. RFC 7489 specifies that discovery does not continue when multiple records remain after filtering.
One implementation detail hides in the sample: a resolver can return a TXT record as multiple character-string chunks. Joining chunks within each DNS record is correct; flattening every chunk from every record into one string is not. The latter can make two records look like one and incorrectly open the gate.
The example also uses optimistic concurrency through revision, although the database implementation is omitted. commit and propose should update only where both id and the old revision match. If zero rows change, another worker won. The loser exits rather than publishing again.
Failure handling is part of the rollout
DNS failure is not negative ownership proof. A timeout, SERVFAIL response, or temporary lookup error should block the transition and be retried with bounded backoff. An NXDOMAIN response or a present-but-wrong token also blocks advancement, but those outcomes deserve different operator messages because their remedies differ.
Never fall forward. If verification is unavailable, retain the current stage. If a customer's proposed record is not visible yet, retain the current stage. If aggregate reports have not been reviewed, do not schedule the next stage. This bias is appropriate because a false advance can disrupt legitimate mail, while a delayed advance preserves the existing policy.
Wait.
Observability should follow the state machine. Emit one structured event per attempt with the rollout ID, normalized domain, old stage, proposed stage, zone ownership type, revision, verification outcome, and reason code. Do not put the raw ownership token in logs. Useful counters include transition attempts, blocked transitions by reason, DNS lookup failures, stale proposals, and revision conflicts. Alert on a sustained change in rates, not on a single customer's expected propagation delay.
Testing needs more than a happy-path unit test. I would cover split TXT chunks, multiple DMARC records, wrong ownership tokens, lookup exceptions, duplicate worker execution, a customer-owned proposal that must not call the writer, and a platform-owned write that is not yet observable. Use a fake resolver at the business-logic boundary. Keep a smaller integration suite against an authoritative test zone to catch assumptions about real DNS responses.
Reports deserve their own ingestion and review path. Aggregate reports can reveal sources that do not align with SPF or DKIM, but they do not grant DNS ownership and should not mutate the rollout directly. A review process can turn report evidence into approval; the scheduled worker still re-verifies DNS immediately before acting.
What I would change at scale
The first change would be separating the scheduler from transition execution with a durable queue. The scheduler enqueues a rollout ID and expected revision. A worker reloads current state, performs the fresh checks, and attempts a conditional update. This makes retries cheap and preserves idempotency across process restarts.
Next, I would replace literal DMARC string comparison with a narrow parser that recognizes tag names case-insensitively where the specification requires it, rejects duplicate critical tags, and compares normalized semantics. I would also query authoritative name servers for high-confidence checks while retaining recursive-resolver observations for a view closer to receivers. Those two views answer different questions, and disagreement is a reason to wait.
Scale does not change the ownership rule. It makes violations harder to notice. Keep customer zones and platform zones in separate authorization paths, use audit records that cannot be silently overwritten, and require explicit approval for the final enforcement step. The final approval is a business control around a technical mechanism; it should name the evidence reviewed and the revision approved.
There is a cost trade-off. More DNS observations, durable events, and report processing consume infrastructure and operator time. For a one-person SaaS, I would pay that cost at transition boundaries and keep steady-state polling modest. Domain policy management is undifferentiated plumbing until it breaks delivery. Outsource commodity queueing and DNS hosting where practical, but keep the state machine and authorization decisions small enough to inspect in one sitting.
The decision rule is uncomplicated: a timer makes a rollout eligible, report review makes it sensible, and fresh ownership plus policy verification makes it authorized. Only then should the state move.
Further reading
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance: https://datatracker.ietf.org/doc/html/rfc7489
- RFC 1035, Domain Names - Implementation and Specification: https://datatracker.ietf.org/doc/html/rfc1035
- RFC 2606, Reserved Top Level DNS Names: https://datatracker.ietf.org/doc/html/rfc2606
- Node.js DNS promises API: https://nodejs.org/api/dns.html#promises-api
Top comments (0)