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>
);
}