You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Feature scope barely affects the bill; architecture does. Two rules that must hold:
One long-running monolith. A continuous polling loop is the worst possible fit for per-invocation billing — 10k domains × 6 records × 6 sweeps/day is ~11M invocations/month. No Inngest, no Upstash, no serverless anything between the scheduler and the resolver. The dashboard on Vercel's free tier is fine; it's request-driven.
Store changes, never observations. Logging every check result is 360k rows/day at 10k domains and turns a $20 bill into $400.
Domain profiles (packages/shared)
Versioned profile definitions — the record set a customer's product requires
Conditional requirements ("if the domain has CAA, require an issue for our CA"; "if MX is present, don't require MX")
Per-domain instruction rendering, including provider-specific name conventions from Phase 1
Hysteresis — N-of-M consensus across vantage points and consecutive-failure thresholds. A single resolver blip must never produce domain.failed. This is the highest-stakes correctness property in the product
degraded is a first-class state distinct from failed
Explicit, tested transition rules with a regression test per rule
Scheduler (packages/jobs + apps/api/src/jobs)
BullMQ + upsertJobScheduler, following the buckt queue/worker/scheduler split.
Adaptive intervals — pending every 30s for 15 min, then 5 min, then hourly; verified + stable 30 days → daily; degraded every 15 min for an hour, then hourly
Floor every interval at the observed TTL
SOA serial fast path — check the zone serial before checking its records; one query instead of six. Full-verify daily regardless, since not every provider bumps it
Workbench mounted at /admin/jobs for queue visibility
API (apps/api)
Hono, /v1 prefix, route-per-file with colocated .spec.ts, Stripe-shaped { data, error, meta } envelope.
POST /v1/domains — register a domain against a profile, with external_id so customers don't need a mapping table
POST /v1/domains/:id/checks — interactive mode: cache-busting, authoritative-first, sub-second
GET /v1/domains/:id/timeline — record change history
GET /v1/dns/lookup — raw lookup by vantage point (the escape hatch)
Idempotency keys, scoped API keys, Redis-backed rate limiting
Webhooks (packages/webhooks)
Own sender, standard-webhooks wire format (webhook-id, webhook-timestamp, webhook-signature, HMAC-SHA256). Svix authored the spec, so swapping in Svix later changes nothing for our customers
deliveries table, exponential retry, circuit breaking on dead endpoints, replay endpoint
Events: domain.verified, domain.verification_failed, domain.degraded, domain.regressed, domain.record.changed, check.completed — each carrying reason, diagnosis codes, and observed values
Persistence (packages/db)
domains — last_checked_at / last_result updated in place
The minimum sellable product. Roughly $30–40/month and about 6–8 weeks.
Gated on #3.
Cost discipline
Feature scope barely affects the bill; architecture does. Two rules that must hold:
Domain profiles (
packages/shared)issuefor our CA"; "if MX is present, don't require MX")The state machine
pending → verifying → verified → degraded → faileddomain.failed. This is the highest-stakes correctness property in the productdegradedis a first-class state distinct fromfailedScheduler (
packages/jobs+apps/api/src/jobs)BullMQ +
upsertJobScheduler, following thebucktqueue/worker/scheduler split.pendingevery 30s for 15 min, then 5 min, then hourly;verified+ stable 30 days → daily;degradedevery 15 min for an hour, then hourly/admin/jobsfor queue visibilityAPI (
apps/api)Hono,
/v1prefix, route-per-file with colocated.spec.ts, Stripe-shaped{ data, error, meta }envelope.POST /v1/domains— register a domain against a profile, withexternal_idso customers don't need a mapping tableGET /v1/domains/:id— status, per-requirement results, observed values, diagnosis codes, rendered instructionsPOST /v1/domains/:id/checks— interactive mode: cache-busting, authoritative-first, sub-secondGET /v1/domains/:id/timeline— record change historyGET /v1/dns/lookup— raw lookup by vantage point (the escape hatch)Webhooks (
packages/webhooks)webhook-id,webhook-timestamp,webhook-signature, HMAC-SHA256). Svix authored the spec, so swapping in Svix later changes nothing for our customersdeliveriestable, exponential retry, circuit breaking on dead endpoints, replay endpointdomain.verified,domain.verification_failed,domain.degraded,domain.regressed,domain.record.changed,check.completed— each carrying reason, diagnosis codes, and observed valuesPersistence (
packages/db)domains—last_checked_at/last_resultupdated in placerecord_changes— append only on actual changechecks— day-partitioned, 7-day retention, auto-droppeduuidv7()text PKs + pgEnum for the state machineDashboard (
apps/web)Full dashboard — front-end work is cheap here, so no need to cut it.
Also
@propgate/sdk— TypeScript only, generated from OpenAPIPOST /v1/domains/:id/simulate { state }plus deterministic fixture domains. Nobody in this space has testability; it's a real differentiatorAcceptance criteria
failedwebhook (proven by test)