Skip to content
DomainKit
Esc
↑↓navigate↵open⌘Jpreview
On this page

Verification and readiness

What DomainKit observes for a domain, how provider evidence differs from public DNS, how a requirement's status is decided, and when to look again.

Verify.observe is the only observation operation. It reads the provider through the attachment’s session and the public pool through Resolver, stores readiness per requirement, and says when to look again.

/**
 * One call reads the provider through the attachment's session and the public pool through
 * `Resolver`, stores readiness per requirement, and says when to look again.
 */
export const check = Effect.map(Verify.observe({ domain }), (readiness) => ({
  ready: readiness.overall === "ready",
  nextCheckAt: readiness.nextCheckAt,
  pending: readiness.requirements
    .filter((requirement) => requirement.status !== "satisfied")
    .map((requirement) => `${requirement.record._tag} ${requirement.record.name}`),
}));
/** Requirements default to the latest apply receipt; pass a set to watch something else. */
export const watchOne = Verify.observe({
  domain,
  requirements: [DnsRecord.txt({ name: `_acme.${domain}`, value: "acme-verify=7f3a" })],
});

Supplied requirements win. Without them, observe uses the attachment’s latest provisioning receipt, meaning the records the apply wrote or found already in place. A domain with neither fails InvalidInput. The route and the browser transport carry the same optional field, so a UI can ask about a set the server has no receipt for.

A domain with no attachment is observed too, as long as it names its requirements. Public DNS stands alone, no provider evidence is recorded, and the readiness row is the same shape.

Three kinds of evidence

Evidence Comes from Names
Provider The provider’s own record list, through the attachment’s session provider
PublicDns One recursive resolver, one entry per resolver that answered resolver
Host Something only your app can see source

A provider readback is not propagation. The record can exist at the provider and still be missing from a resolver, and the two are kept apart so a stuck setup is diagnosable.

Every entry explains itself. Provider and PublicDns carry values, what that source returned for the requirement’s name and type, rendered the way DnsRecord.data renders a record and empty when the source returned nothing. All three carry detail, which is null when the requirement is satisfied and otherwise says why it is not: expected edge.acme.dev; found other.acme.dev, no TXT record at _acme.app.example.com, google timed out.

/**
 * Every requirement keeps the evidence behind its status, one entry per source that answered.
 * `values` is what that source returned for the record's name and type, empty when it returned
 * nothing; `detail` is null when satisfied and otherwise says what went wrong.
 */
export const sources = Effect.map(Verify.observe({ domain }), (readiness) =>
  readiness.requirements.flatMap((requirement) =>
    requirement.evidence.map((evidence) => ({
      record: requirement.record.name,
      status: requirement.status,
      source:
        evidence._tag === "Provider"
          ? evidence.provider
          : evidence._tag === "PublicDns"
            ? evidence.resolver
            : evidence.source,
      found: evidence._tag === "Host" ? [] : evidence.values,
      detail: evidence.detail,
    })),
  ),
);
/** Merge what only your app can see — an email identity, a certificate — without re-reading DNS. */
export const recordCertificate = Effect.gen(function* () {
  const observedAt = yield* DateTime.now;
  return yield* Verify.attachEvidence({
    domain,
    evidence: [
      new Verify.HostEvidence({
        source: "edge-certificate",
        status: "pending",
        label: "TLS certificate",
        detail: "Issuance starts once the CNAME resolves",
        observedAt,
      }),
    ],
  });
});

attachEvidence merges by source without re-reading DNS, so a webhook can update one signal without paying for a full observation. Host evidence merged before the first observe is kept, but the domain is never ready until DNS has been observed: it is failed when a host signal failed and otherwise pending, with nextCheckAt set for that first observation.

Statuses

Requirement status Means
satisfied An exact match was observed and no constraint is violated
missing Nothing matched
mismatch A constraint is violated: an exclusive requirement met another record at that name and type, or an SPF requirement met a different or duplicate SPF record
unknown No source answered

A constraint outranks an exact match. An exclusive requirement is mismatch when any incompatible record shares its name and type, even with the exact record present, and an explicit DnsRecord.spf requirement is mismatch when the name holds a different SPF record or more than one SPF record. Unrelated TXT doesn’t affect an SPF requirement. Planning and verification apply the same rules.

The domain’s overall is failed when any requirement is a mismatch or any host evidence failed, ready when at least one requirement has been observed, every requirement is satisfied, and every host signal is ok, and pending otherwise. Host evidence can hold a domain back but never makes it ready alone.

The resolver pool

The default Resolver fans out to Cloudflare and Google over RFC wire-format DNS-over-HTTPS with a three-second timeout, and keeps every answer, so readiness can say “found at Cloudflare, missing at Google”. Resolver.resolve never fails: a failure or a timeout is a per-resolver outcome.

A wire response counts only when its message id is zero, its single question matches the query in name, class, and type, it is not truncated, and every answer carries a TTL.

/** Quorum and the backoff ladder are references with defaults; override either per call tree. */
export const strict = check.pipe(
  Effect.provideService(Verify.Policy, {
    ...Verify.defaults,
    quorum: "all",
    backoff: (pendingForMs) => (pendingForMs < 60_000 ? 5_000 : 60_000),
  }),
);

quorum decides how many resolvers must agree for satisfied: any by default, or all, or a minimum. A mismatch at any resolver outranks agreement elsewhere.

When to look again

/** Readiness carries its own schedule, so a worker sleeps until `nextCheckAt` instead of polling. */
export const dueAt = Effect.map(
  Verify.latest(domain),
  (readiness) => readiness?.nextCheckAt ?? null,
);

nextCheckAt comes from the backoff ladder, 15 seconds, 1 minute, 5 minutes, 30 minutes by default, measured from when the current pending streak began. The streak restarts when the requirement set changes and clears when the domain is ready, so a worker sleeps on the readiness row instead of polling on a timer of its own.

Host-owned observation

The stored readiness is the single fact about a domain. A host that runs its own clock observes where it decides to, reads the stored fact back everywhere else, and never keeps a copy of it.

Verify.Observer is where a host learns that DomainKit wrote readiness. It fires once per stored readiness, after the write and outside any transaction, with the readiness, the ownerId it was stored under, and whether an observation or merged host evidence caused it. The observer is bound to the layer, not to one request, so a host that serves more than one tenant keys its projection by ownerId and domain: the same domain name can be attached in two tenants at once. A failure or defect in the observer is logged and swallowed, because the observation is already durable and losing it to a broken projection would make the stored fact depend on the host’s side effects; work that has to survive belongs on a durable queue the observer enqueues to.

Events for one domain are not ordered against each other. Two observations that overlap both store, the later write wins in Storage, and their callbacks can finish in either order, so a projection compares readiness.checkedAt and keeps the later one. Verify.latest is the authority.

/**
 * `Verify.Observer` fires once per stored readiness, after the write. It is the one place a host
 * learns that DomainKit wrote readiness, so a projection lives here instead of beside every call
 * that might have caused one. The observer is bound to the layer rather than to one request, so
 * the event names the tenant it was written for: key the transition by `ownerId` and `domain`,
 * because the same domain can be attached in two tenants at once.
 */
const observer = Layer.succeed(Verify.Observer, {
  readinessChanged: ({ cause, domain, ownerId, readiness }) =>
    Effect.gen(function* () {
      if (readiness.overall === "ready") return yield* markReady({ ownerId, domain });
      // A pending domain carries its own schedule, so the host sleeps on it rather than polling.
      // Merged host evidence keeps the schedule an observation already set; before the first
      // observation there is none, so evidence on a row with no requirements schedules it.
      const schedules = cause === "observe" || readiness.requirements.length === 0;
      if (readiness.nextCheckAt !== null && schedules) {
        yield* wake({
          ownerId,
          domain,
          at: new Date(DateTime.toEpochMillis(readiness.nextCheckAt)),
        });
      }
    }),
});

export const live = DomainKit.layer({ providers }).pipe(Layer.provideMerge(observer));
/**
 * The clock is the host's. A job observes the domains that are due, and the observer above is what
 * tells the rest of the application that something changed. A domain with nothing to observe (no
 * receipt and no requirements) is logged and skipped, so it does not stop the batch; any other
 * failure fails the job, so your queue retries it.
 */
export const sweep = Effect.gen(function* () {
  const due = yield* domainsDue;
  yield* Effect.forEach(
    due,
    (domain) =>
      Verify.observe({ domain }).pipe(
        Effect.catch((error) =>
          error.reason._tag === "InvalidInput"
            ? Effect.logWarning(`nothing to observe for ${domain}`, error)
            : Effect.fail(error),
        ),
      ),
    { concurrency: 8 },
  );
}).pipe(Effect.provideService(Principal.Service, principal));

Verify.latest reads the stored readiness without observing, and Verify.summary counts it into { observed, total, satisfied, missing, mismatch, unknown }, so an API or a page answers from the one fact rather than deciding again. Every requirement carries key, its record’s type, name, and data, which is what a host pairs its own rows against instead of trusting array position.

/**
 * A page or an API answers from the stored fact. `Verify.latest` reads it without observing, and
 * `Verify.summary` counts it, so nothing about the domain is decided twice.
 */
export const snapshot = (domain: string) =>
  Effect.map(Verify.latest(domain), (readiness) => ({
    readiness,
    ...Verify.summary(readiness),
  }));

The same readiness reaches the browser. GET /domains/:domain/readiness and Transport.verification.latest return it or null, and Domain.useFlow({ verification }) renders what the host supplies: no observation on mount, no timer, and the surface’s “check again” calling the host’s. A surface given no verification observes and polls on its own, which is the right default for a host with no clock of its own.

/**
 * The host owns the clock: it reads the stored readiness on its own schedule, and the flow renders
 * what it was given. Nothing observes on mount, no timer runs, and `observe` and `retry` on the
 * surface ask the host for a fresh reading.
 */
function HostObservedSetup({ domain }: { readonly domain: string }) {
  const [readiness, setReadiness] = useState<Verify.Readiness | null>(null);
  const refresh = useCallback(() => {
    void api.verification?.latest(domain).then(setReadiness);
  }, [domain]);
  useEffect(refresh, [refresh]);

  const flow = Domain.useFlow({
    domain,
    requirements,
    verification: { readiness, observe: refresh },
  });
  const counts = Verify.summary(flow.readiness);
  return (
    <section>
      <p>{counts.observed ? `${counts.satisfied} of ${counts.total} found` : "Not checked yet"}</p>
      <table>
        <tbody>
          {flow.requirements.map((record) => {
            // Two facts, two columns: what a pending plan will do, and what was read back.
            const standing = Records.standingOf(record, {
              plan: flow.plan,
              readiness: flow.readiness,
            });
            return (
              <tr key={Records.identity(record)}>
                <td>{record.name}</td>
                <td>{standing.planned === null ? null : standing.planned._tag}</td>
                <td>{standing.observed?.status ?? "not checked"}</td>
              </tr>
            );
          })}
        </tbody>
      </table>
      <button onClick={() => flow.verification.observe()} type="button">
        Check again
      </button>
    </section>
  );
}

export function HostObserved({ domain }: { readonly domain: string }) {
  return (
    <DomainKit.Root transport={transport}>
      <HostObservedSetup domain={domain} />
    </DomainKit.Root>
  );
}

Last updated on

Was this page helpful?