Short answer: enforce the domain quota from a reconciliation job, not from a tenant table that can be stale. Treat DNS as the observed state, make every SPF, DKIM, and DMARC change idempotent, and apply a different quota policy to customer-owned domains than to platform-owned zones. A Node.js service can request the work, but the durable decision belongs in an auditable control loop.
That distinction matters in an edtech platform. A school may bring district.example and ask for branded mail, while another school uses a platform-provided subdomain. Both can appear as rows in the same tenant table, yet they have different authority, verification, and cleanup obligations. Mail delivery is the visible outcome; the quota is an invariant behind it.
Why a tenant table is not the source of truth
The table records intent. DNS records, delegated nameservers, and registrar state record what the outside world can actually observe. A transaction can commit a new domain row and then lose the worker before the TXT record is published. A retry can publish the record but fail before marking the row active. A human can remove a CNAME at the registrar. None of those events is repaired by counting rows.
The failure mode is especially awkward for DMARC: receivers evaluate the policy that is published at _dmarc.<domain>, and reports can arrive long after the original change. SPF has a lookup-count limit, while DKIM depends on a selector and a public key at a predictable name. These are protocol constraints, not application metadata.
I model each desired record as a deterministic object: owner name, record type, normalized value, and an ownership token. The reconciler reads that desired set, reads DNS, computes a diff, and records an observation with a timestamp and correlation ID. It never increments a counter merely because an API call was accepted.
One small rule prevents a large class of incidents: count a domain only after its required authentication records are observed and verified. Pending work consumes a reservation with an expiry, so a crashed workflow cannot permanently consume the tenant's allowance.
That is the invariant.
How should a tenant enforce a domain quota with reconciliation instead of trusting a table in Node.js?
The implementation language is incidental. A Node.js API can enqueue a reconciliation request, while a worker written in any language applies the same state machine. The state transitions should be explicit: requested, reserved, published, verified, and released. Each transition is keyed by (tenant_id, canonical_domain, record_kind) so retries are harmless.
Here is the accounting core in Go. It deliberately separates reservations from verified usage; the database transaction that creates a reservation is not allowed to claim that DNS already matches.
package quota
import (
"context"
"strings"
)
type DomainState string
const (
Requested DomainState = "requested"
Reserved DomainState = "reserved"
Verified DomainState = "verified"
Released DomainState = "released"
)
type Domain struct {
TenantID string
Name string
OwnedBy string // "customer" or "platform"
State DomainState
}
type Store interface {
CountActive(ctx context.Context, tenantID, owner string) (int, error)
Get(ctx context.Context, tenantID, name string) (Domain, error)
PutReservation(ctx context.Context, d Domain) error
}
func Reserve(ctx context.Context, s Store, tenantID, name, owner string, limit int) error {
canonical := strings.TrimSuffix(strings.ToLower(name), ".")
active, err := s.CountActive(ctx, tenantID, owner)
if err != nil {
return err
}
if active >= limit {
return ErrQuotaExceeded
}
return s.PutReservation(ctx, Domain{
TenantID: tenantID,
Name: canonical,
OwnedBy: owner,
State: Reserved,
})
}
The production version also uses a uniqueness constraint on tenant, canonical name, and owner, plus an idempotency key supplied by the caller. Without both, two concurrent requests can each observe room under the limit. The reconciler then verifies the TXT, CNAME, and MX prerequisites appropriate to the deployment, and moves the reservation to verified only when DNS answers agree with the desired records.
Customer-owned versus platform-owned zones
Ownership changes the blast radius. For a customer-owned zone, the platform usually publishes a challenge record and waits for delegation or a CNAME target; the customer retains authority and can revoke it. For a platform-owned zone, the platform controls the authoritative zone and can publish DKIM selectors and DMARC policy directly, but it also carries the responsibility for collisions, key rotation, and tenant isolation.
| Decision | Customer-owned domain | Platform-owned zone |
|---|---|---|
| Authority | Customer or its DNS operator | Platform DNS account |
| Verification | Challenge plus repeated DNS lookup | Internal authorization plus DNS lookup |
| Quota unit | Verified apex or delegated subdomain | Allocated zone or sender identity |
| Cleanup | Release after revocation is observed | Delete records after retention window |
| Main risk | Stale delegation and delayed propagation | Cross-tenant record collision |
Do not silently merge these units. A tenant that owns five domains should not get five more platform subdomains because the table happens to use one domain_count column. Store the ownership class, define separate limits where policy requires it, and expose the reason for a rejection in an audit event.
The catch is operational: customer DNS can take hours to converge, and some administrators will publish a syntactically valid but semantically wrong value. A strict synchronous request would make the product feel broken. An asynchronous reservation with a visible expiry is more honest; it also gives support staff a precise record to inspect.
Reconciliation, audit, and delivery safety
A useful loop has four phases. First, load the desired records and active reservations. Second, query authoritative DNS, following CNAMEs only where the deployment policy permits. Third, compare normalized RRsets, not raw text, because ordering and a trailing dot should not create false drift. Finally, write an immutable observation and apply the state transition in a transaction.
The details of that loop matter more than its name. The worker should pin a resolver policy, record the nameserver that answered, and retain the exact RRset used for the comparison. It should distinguish NXDOMAIN from an empty answer, because the former indicates that the name is absent while the latter can be a valid state for a record type. A timeout is neither proof of absence nor proof of success; it schedules another attempt with bounded backoff. When a customer-owned domain changes delegation, the reconciler should pause destructive cleanup until the old authority has been absent for the policy's retention window. For a platform-owned zone, it can perform the same comparison against the authoritative provider and immediately flag a collision. Those distinctions make the audit trail useful during a mail-delivery investigation, where a timestamp and resolver identity are often more valuable than a green status badge.
No shortcut.
The loop must tolerate duplicate work. Use a stable operation ID, conditional updates such as WHERE state = 'reserved', and an outbox event for notifications. If a worker dies after publishing DKIM but before writing its observation, the next run sees the record and completes the same operation. Exactly-once effects come from idempotent writes and reconciliation, not from pretending a distributed queue is exactly once.
SPF, DKIM, and DMARC also have compliance edges. DMARC reporting addresses can contain sensitive operational data; retain reports only as long as policy and applicable privacy rules allow. Keep private DKIM keys outside the DNS publishing path, rotate selectors without deleting the still-referenced key, and log who approved a policy change. RFC 7489 defines the DMARC record and reporting model; it does not define your tenant quota, so that invariant must be documented and tested by your service.
I once assumed a successful publish response was enough to release a reservation. It was not: a resolver cache still served the old TXT value, and the next retry created a second selector. The corrected workflow waited for an observation matching the canonical RRset and emitted one audit event per transition. Your mileage may vary with resolver TTLs, but the principle is stable.
A rollout rule that survives retries
Start with shadow reconciliation: calculate drift and quota usage without changing DNS, and compare the result with the tenant table. Then enforce reservations for new domains, leaving existing records in a grandfathered state until they are verified or explicitly retired. Alert on reservations older than their expiry, repeated ownership changes, and a growing gap between desired and observed RRsets.
This approach is not suitable when a tenant needs arbitrary DNS hosting, wildcard record management, or instant, strongly consistent visibility at every recursive resolver. Keep a specialized DNS control plane for those cases, and let the mail service consume its verified events. For ordinary branded mail, a small reconciler with clear ownership classes is easier to reason about than a larger quota table that claims certainty it cannot possess.
Top comments (0)