DEV Community

JamesAnderson121
JamesAnderson121

Posted on

Tenant DNS Record Types in Python: Let the Consumer Define the Contract

Short answer: a DNS record type is part of a consumer-owned lookup contract, so publish the owner name, type, and value that the receiving system actually queries; for a tenant mail subdomain using DMARC, that means validating the TXT policy at _dmarc.<tenant-domain> and collecting receiver reports before tightening enforcement.

A successful write to a DNS control plane proves only that a write happened. It doesn't prove that the intended consumer can discover, parse, or act on the result. That distinction matters in a media platform that creates tenant.news.example automatically: the web subdomain can work while the mail-authentication contract remains absent or malformed.

The useful experiment is therefore not "did provisioning return success?" It is "can an independent resolver retrieve the exact resource-record type at the exact owner name, and does its content satisfy the consumer's grammar?" RFC 7489 gives us a concrete case because DMARC policies are published in DNS TXT records, begin with the v=DMARC1 version tag, and are evaluated by mail receivers.

Why does the DNS consumer decide the record type contract?

DNS stores typed resource records. The application that consumes a protocol chooses which tuple it will ask DNS for: owner name and record type, followed by a parser for the returned data. The zone owner controls what gets published, but publishing authority is not protocol authority. If the consumer asks for TXT at _dmarc.tenant.news.example, identical-looking characters stored under some other record type don't answer that query.

The consumer decides.

Think of the record type as a function parameter, not presentation. A dictionary may contain the string you wanted, yet a call using a different key still returns no value. The same mistake is easy to hide in tenant automation because a provisioning model often has friendly fields such as kind, host, and content. Those labels are local conveniences. The receiver has no knowledge of them.

DMARC sharpens the point. RFC 7489 defines a policy record discovered at a specific DNS owner name and represented as a TXT resource record. Its v tag identifies the DMARC version, while p states the requested policy. Receivers then perform the protocol work: they evaluate authenticated identifiers and alignment, apply the published policy according to the specification, and can send aggregate feedback to a URI named by rua. The publisher expresses a request. The receiver interprets it.

This also explains why record creation and deliverability evidence are different artifacts. A provider dashboard, database row, or 201-style success state belongs to the producer side. A resolver result and a receiver-generated aggregate report belong to the consumer side. For notebook-to-production work, I treat that boundary like an eval boundary — assertions before it check configuration intent; assertions after it check observable behavior.

Small distinction. Big operational consequence.

A focused Python contract check

The first implementation is often too simple: write a record, read the provisioning object back, and mark the tenant ready. That test exercises one storage path twice. It can pass even when the public lookup name is wrong, the requested resource-record type is wrong, or the returned value doesn't start with the version marker the consumer expects.

The better shape is a narrow resolver interface plus a protocol-specific evaluator. The resolver adapter can be backed by any DNS implementation; the evaluator stays deterministic, cheap to run in an eval suite, and independent of a control-plane SDK. In this example the adapter's contract says each returned string is one complete TXT record, with any wire-level character-string chunks already joined. That detail keeps transport representation out of policy parsing.

from dataclasses import dataclass
from typing import Protocol, Sequence


class Resolver(Protocol):
    def resolve_txt(self, owner: str) -> Sequence[str]: ...


@dataclass(frozen=True)
class DmarcEvidence:
    owner: str
    record_count: int
    version: str | None
    policy: str | None
    valid: bool
    reason: str


def evaluate_tenant_dmarc(domain: str, resolver: Resolver) -> DmarcEvidence:
    owner = f"_dmarc.{domain.rstrip('.').lower()}"
    records = [value.strip() for value in resolver.resolve_txt(owner)]
    candidates = [value for value in records if value.startswith("v=DMARC1")]

    if len(candidates) != 1:
        return DmarcEvidence(
            owner, len(candidates), None, None, False,
            "expected exactly one DMARC policy record",
        )

    fields: dict[str, str] = {}
    ordered_tags: list[str] = []
    for field in candidates[0].split(";"):
        if not field.strip():
            continue
        name, separator, value = field.partition("=")
        if not separator:
            return DmarcEvidence(
                owner, 1, None, None, False, "tag has no value"
            )
        tag = name.strip().lower()
        fields[tag] = value.strip()
        ordered_tags.append(tag)

    version = fields.get("v")
    policy = fields.get("p")
    valid = (
        ordered_tags[:1] == ["v"]
        and version == "DMARC1"
        and policy in {"none", "quarantine", "reject"}
    )
    reason = "contract matched" if valid else "invalid version or policy"
    return DmarcEvidence(owner, 1, version, policy, valid, reason)
Enter fullscreen mode Exit fullscreen mode

This is deliberately a focused gate, not a complete DMARC implementation. It verifies the discovery tuple and a small, high-value part of the record grammar. A production validator should use a standards-complete parser before claiming full conformance, especially when optional tags are admitted. Don't let a compact sample quietly become the organization's second email-authentication parser.

The return value is structured because boolean checks age badly. When a tenant fails activation, record_count=0 points to discovery, while an unexpected policy points to content. That evidence is also easy to retain beside a deployment revision without storing prompts, SDK objects, or provider-specific response shapes. Tests can inject a tiny fake resolver and cover zero, one, and multiple candidate records without making live DNS queries.

Deliverability evidence needs more than a green DNS write

For an automated tenant launch, evidence should cross the same boundaries that real mail crosses. Start with intended state: the normalized tenant domain, expected owner name, resource-record type, and expected policy. Then observe public DNS through an independent resolver path. Parse the result with the same strictness expected from the protocol. Finally, inspect aggregate DMARC reports from participating receivers, because RFC 7489 defines those reports as feedback about authentication results and policy disposition.

No shortcut proves all four layers.

Those layers answer different questions:

Evidence Question answered What it cannot prove alone
Provisioning receipt Was the requested change accepted? Public discovery or receiver behavior
TXT lookup at _dmarc.<domain> Is the policy visible at the consumer's lookup tuple? Mail-stream alignment over time
Parsed v and p tags Does the retrieved value match the basic contract? How receivers observed actual messages
Aggregate receiver reports What authentication and disposition did receivers report? That every possible receiver behaves identically

The long paragraph belongs here because the common failure is a category error, not a syntax error. Imagine the tenant model stores tenant.news.example, while one worker constructs _dmarc.news.example from the parent and another constructs _dmarc.tenant.news.example from the tenant domain. Both strings are valid DNS names. Both jobs may complete. A read-after-write against each worker's own desired-state object can stay green forever. Only a test fixture that starts with the tenant domain, derives the expected DMARC owner once, queries TXT externally, and parses the answer as a receiver-facing policy catches the disagreement. The fix is architectural: make a single contract object the input to provisioning, observation, and evaluation, rather than rebuilding owner names in three code paths.

Timing needs careful language. DNS observations can differ while cached data ages, so one failed lookup is evidence to investigate, not permission to guess at a different record type. Record the observation time and resolver vantage point, retry according to the rollout policy, and keep the tenant in a pending state until the consumer-side check passes. I'm not sure what delay budget fits every media product; the existing DNS settings, launch urgency, and evidence from the chosen resolver paths should determine it.

Reports need similar restraint. A DMARC aggregate report is feedback from a receiver that participates in reporting, not a universal census. Absence of a report is not proof that a particular message failed, and one healthy report is not proof about all traffic. It is still valuable evidence because it comes from the far side of the contract.

What should a tenant rollout measure before enforcing policy?

Begin with observation. RFC 7489 defines p=none as a request for no specific delivery action based on DMARC, which makes it suitable for gathering data before asking receivers to quarantine or reject failing mail. It also defines the pct tag as the percentage of messages to which the requested policy applies, from 0 through 100, with a default of 100. Those controls support a staged policy decision, but they don't repair identifier alignment or a wrong DNS lookup tuple.

I don't call a rollout complete at pct=50; that number describes requested policy application, not whether the DNS contract is discoverable. The activation gate should separately require a successful TXT observation, valid policy parsing, and a reporting destination the team is prepared to operate. After mail flows, the team can evaluate report data for expected sources and authentication alignment before changing enforcement. The exact promotion threshold is organization-specific — your mileage may vary — and should be written down before the first tenant becomes the test case.

There is a real limitation to per-tenant policy publication. It adds tenant-scoped state, observation work, and failure handling. It is not suitable when every subdomain intentionally shares one centrally managed mail policy and no tenant needs an independent rollout or reporting boundary; in that case, stick with the organizational policy behavior defined by DMARC and test that fallback path. Choose explicit tenant records when tenant isolation, separate evidence, or different enforcement timing justifies the extra operational surface.

Cost belongs in the decision, but primarily as evaluation cost: DNS lookups, report ingestion, storage retention, and repeated parsing all consume resources when multiplied by many tenants. Keep the hot check narrow, store structured evidence, and run deeper report analysis asynchronously. Prompt-cost awareness points in the same direction if reports later feed an AI-assisted investigation: aggregate deterministic counters first, then send only the anomalous slice for model review. A language model should explain evidence, not decide whether _dmarc.<domain> exists.

Measure before copying this design: how many tenants actually send mail, which ones require an independent policy, whether the team can receive and retain aggregate reports, how long consumer-side discovery takes across chosen resolver vantage points, and which parsed conditions block activation. If those questions have no owner, automatic subdomain creation is ahead of automatic domain operations.

References

Further reading

Top comments (0)