The constraint that decides this design isn't your database. It is the cache in front of the resolver you happen to call: every answer about a custom domain carries a TTL, and a "there is no such record" answer is cached as well. So use a live record read as the source of truth for the onboarding screen, keep the stored flag as nothing more than a cache of the last observation, and never let that flag drive what the UI claims about SPF, DKIM or DMARC.
I came at this from ledger work, where the rule is old and boring: a balance you computed yesterday is a projection, and the journal is the authority. Domain verification has the same shape, with one nasty difference — the journal lives in a zone file that your customer's IT contractor can edit at 02:00 on a Sunday without telling anybody. A support desk that sends password resets and ticket notifications from support.customer.example has exactly one way to know whether that domain is still publishing a DKIM key: ask.
The stored flag doesn't survive contact with a key rotation.
Why a stored verification flag drifts, and how fast
Three records have to be present for a support mailbox to deliver reliably. SPF is a TXT record at the organizational domain beginning with v=spf1. DKIM is a TXT record at selector._domainkey.<domain> carrying the public key. DMARC is a TXT record at _dmarc.<domain> beginning with v=DMARC1, and RFC 7489 requires a policy tag there.
Set a boolean when all three first resolve, and you have written down a fact with no expiry. Every later event that invalidates it is invisible to you: a selector retired after a key rotation, an SPF record rewritten by a different vendor's wizard and truncated past ten DNS lookups, a second DMARC record pasted in by a well-meaning consultant. That last case is the one worth internalizing, because it is not a gray area. RFC 7489 says a set containing multiple DMARC records means policy discovery terminates and DMARC is not applied to the message. Two valid-looking records are worse than one, and a substring check for v=DMARC1 reports success in exactly the situation where receivers apply no policy at all. SPF has the same trap from the other direction: RFC 7208 makes more than one matching record a permanent error rather than a lucky merge.
A stored flag records that you once saw a correct configuration. It cannot record that the configuration is still correct, and in a customer support product the gap between those two statements is measured in undelivered ticket replies.
How should the onboarding UI read live DNS records instead of a stored flag?
Render the screen from an observation, not from a verdict. An observation is a small immutable row — the name queried, the resolver asked, the timestamp, a hash of the answer set, and a derived state of present, absent or ambiguous. The UI reads the newest observation for each check. A background poller and the customer's "check again" button both write observations; neither one mutates a boolean.
That indirection buys three things at once. Retries become free, because writing the same observation twice is harmless and the hash tells you whether anything actually changed. The state transition that matters commercially — flipping a tenant to "allowed to send" — fires once per distinct answer hash instead of once per poll. And when somebody asks in six months why mail stopped on a Tuesday, the answer is a row with a timestamp rather than a shrug.
Here is the read path itself, in Go:
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net"
"sort"
"strings"
"time"
)
// Observation is what we persist: evidence of one read, never a verdict.
type Observation struct {
Domain string
Check string // "spf", "dkim", "dmarc"
Name string // owner name actually queried
Resolver string
ObservedAt time.Time
RRSetHash string
State string // "present", "absent", "ambiguous"
Detail string
}
var errAmbiguous = errors.New("multiple policy records published at one name")
// resolverAt pins lookups to a chosen resolver so the observation says who answered.
func resolverAt(addr string) *net.Resolver {
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
d := net.Dialer{Timeout: 3 * time.Second}
return d.DialContext(ctx, network, addr)
},
}
}
func readDMARC(ctx context.Context, r *net.Resolver, addr, domain string) (Observation, error) {
name := "_dmarc." + domain
obs := Observation{
Domain: domain, Check: "dmarc", Name: name,
Resolver: addr, ObservedAt: time.Now().UTC(),
}
txts, err := r.LookupTXT(ctx, name)
if err != nil {
var dnsErr *net.DNSError
// Name error or no TXT at this name: absent is a real answer, not an outage.
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
obs.State, obs.RRSetHash = "absent", hashRRSet(nil)
return obs, nil
}
return obs, err // transport trouble: keep the previous observation, show it as stale
}
obs.RRSetHash = hashRRSet(txts)
var policies []string
for _, txt := range txts {
// RFC 7489 6.6.3: only records carrying the current version tag count.
if strings.HasPrefix(strings.TrimSpace(txt), "v=DMARC1") {
policies = append(policies, txt)
}
}
switch len(policies) {
case 0:
obs.State = "absent"
case 1:
obs.State, obs.Detail = "present", policies[0]
default:
obs.State = "ambiguous"
obs.Detail = fmt.Sprintf("%d DMARC records at %s", len(policies), name)
return obs, errAmbiguous
}
return obs, nil
}
// hashRRSet makes two polls comparable, so a tenant is enabled once per distinct answer.
func hashRRSet(txts []string) string {
sorted := append([]string(nil), txts...)
sort.Strings(sorted)
sum := sha256.Sum256([]byte(strings.Join(sorted, "\x00")))
return hex.EncodeToString(sum[:])
}
Two details in that code are the whole point. A not-found answer is recorded as absent with a hash of the empty set, because "the customer hasn't published it yet" is information; a dial timeout returns an error and leaves the last good observation in place, marked stale in the UI rather than silently downgraded to "not verified". Conflating those two is how a support team ends up telling a customer to re-paste records that were already correct.
One more thing the parser has to get right: RFC 1035 caps a character-string at 255 octets, so a 2048-bit DKIM key arrives as several strings inside one record and must be joined before the p= tag is read. Split it wrong and you reject a perfectly good key.
Propagation delay versus cutover speed
This is the axis that actually shapes the product, and the tension is real: a short TTL lets the UI turn green minutes after the customer saves their zone, while a long TTL protects you from hammering authoritative servers and gives resolvers a stable answer. The trap is negative caching. RFC 2308 makes the SOA MINIMUM field the lifetime of a cached negative answer, bounded by that SOA's own TTL, which means a verification attempt fired one minute before the customer pressed save can pin "no such record" into a shared resolver cache for an hour or more. You created the delay you are now polling against.
So the read path and the rollout plan are one design, not two:
| Window | TTL published on the records | Poll cadence | What the screen says |
|---|---|---|---|
| T-24h, before cutover | lower to 300 s | none; don't query yet | "waiting for the old TTL to expire" |
| Cutover | 300 s | every 60 s, per tenant, capped | newest observation, ambiguous states shown verbatim |
| Steady state | raise to 3600 s | hourly, plus page loads behind a 30 s cache | newest observation with its timestamp |
The "don't query yet" row is the one teams skip. Deferring the first lookup until the customer says they have saved the record costs you nothing and avoids minting a negative cache entry that outlives the fix. For the manual button, query the zone's authoritative servers directly and label the result as such — it answers "is it published?" without waiting on anyone's cache, which is precisely the question a customer on a call is asking:
dig +noall +answer TXT _dmarc.support.customer.example
dig +noall +answer @ns1.registrar.example TXT _dmarc.support.customer.example
Show both. When they disagree, the difference is propagation, and saying so turns a support ticket into a two-minute explanation.
What to keep, and where a flag is still the right answer
Migrating an existing onboarding flow is mostly subtraction. Keep the boolean column, stop writing to it from the UI path, and backfill it from the newest observation so dashboards and billing queries keep working while you cut over. Dual-read for a week. Then make the flag derived, and let anything that needs an authoritative answer read the observation table instead.
Retention deserves a decision rather than a default. Observations are small, they are the evidence trail for "when did this tenant stop being compliant", and a year of them costs less than one argument with an auditor — but DMARC aggregate reports are a different class of data, since they identify sending sources by IP address and belong in whatever retention schedule your privacy policy already commits to. Don't let them ride along in the same table by accident.
The catch is that live reads are not free, and I'd be careful about claiming they are always the right call. Every page load that triggers a lookup is a query you owe someone else's infrastructure, so a per-tenant cache of 30 seconds and a hard rate limit on the manual button aren't optional. If your product sends mail from domains you control, this whole design is overhead — stick with a stored flag and a deploy-time check, because nothing changes underneath you. The same goes for onboarding flows where a human approves each domain anyway: a queue with an audit log already gives you the reconciliation story, and adding a poller buys little.
Where I'm genuinely unsure is cadence for very large tenant counts. Sixty-second polling across tens of thousands of domains is a lot of traffic for a state that changes twice a year, and the honest answer may be event-driven revalidation — check on send failure, on bounce rate change, on a DMARC report arriving with an unexpected source — rather than a clock. Your mileage may vary with how much you trust your bounce signal.
Either way, the screen should never claim something the resolver didn't just say.
Top comments (0)