UQRP Protocol Specification

Draft

Universal Query Response Protocol for DNS-Based Data Storage

Live DNS Query
dig TXT
Try:

Overview

ResolveDB encodes public service data and hosted-record reads in DNS queries and responses. Public answers can use shared DNS caching; authenticated private answers always use TTL 0.

Implementation Status

This specification describes both implemented features and planned capabilities.

CategoryStatusNotes
DNS message parsingImplementedRFC 1035 compliant
TXT response formatImplementedv=rdb1 format
Status/error envelope vocabularyPartialSuccessful data envelopes are shipped; request failures primarily use DNS RCODEs
Error codes E001-E013PartialDefined vocabulary; not every production path emits an envelope code
Error code E014ReservedNot emitted; UDP/TCP/DoH/DoT use the same query gate
Error codes E015-E022PlannedSecurity errors
JWT authentication (EdDSA)Library only / plannedUtility exists, but authoritative DNS and DoH request paths do not call it
Namespace query tokens (auth-rdbq…)ImplementedSynced opaque tokens; private namespaces answer REFUSED without one
DNSSEC signingImplementedECDSA P-256 KSK, Ed25519 ZSK, NSEC black lies
TTL behaviorImplementedPublic answers are cacheable; authenticated private answers always use TTL 0
DoH RFC 8484 (wire format)ImplementedGET/POST /dns-query
DoH JSON APIImplementedGET /resolve (Google-compatible)
DoT RFC 7858ImplementedPort 853 via dnsdist
Schema endpoint (info operation)ImplementedJSON Schema via DNS + HTTP /schema
Public services (units, sun, moon)ImplementedTokenless public.v1; sun location resolution may use providers, moon without a date changes daily
btc chain-stats resourceReserved (gated off)BTC_SERVICE_ENABLED default false; mock data (src=mock)
Namespace validationImplementedRails enforces DNS-label syntax, length, uniqueness, and reserved names
Pagination (cursor-based)PlannedHMAC-signed cursors
Special tokens (BDT, CTP)PlannedPrivacy tokens
NULL record mitigationsPlannedAmplification limits
EDNS Client SubnetCompatibility onlyJSON parameter is echoed, not used for routing

Current implementation details live in this monorepo's resolvedb-core/ directory.

Explicit Parameters Design

ResolveDB uses explicit parameters for all context-dependent queries. The server never infers client identity, location, or preferences from the source IP address. This design provides predictability, privacy, cache efficiency, and auditability.

Core Principles

PrincipleImplementation
Explicit lookup targetIdentical queries select the same target regardless of source; time-varying/provider data can change
Explicit parameters onlyLocation, IP, and context MUST be provided as query parameters
No source IP inferenceServer MUST NOT use the querier's IP for any business logic
Proxy-transparentQueries through VPNs, DoH, or proxies work identically to direct queries

Benefits

BenefitDescription
PredictabilitySame query selects the same explicit target regardless of source; provider and time-varying data can still change.
Cache efficiencyNo ECS scope fragmentation. One cache entry can serve clients sharing the same recursive cache instance.
PrivacySource IP is not substituted as query data. Direct DoH still sees the connecting IP.
AuditabilityInspect any query string to see exactly what data the server receives.
CompatibilityWorks through DoH/DoT resolvers, VPNs, corporate proxies, Torβ€”all correctly.

Why Traditional GeoDNS Breaks

Traditional DNS-based services infer client location from source IP, causing:

  1. Unpredictable results - Same query returns different data from different networks
  2. Proxy/VPN breakage - Queries return data for the proxy's location, not yours
  3. Cache fragmentation - ECS-scoped responses create thousands of cache entries per /24 subnet
  4. Privacy leakage - Server logs reveal your approximate location
  5. Testing difficulty - Can't reproduce production behavior in CI/staging

Explicit Parameter Pattern

# CORRECT: Client explicitly provides context
geoip.ip-8-8-8-8.public.v1.resolvedb.net              # GeoIP for specific IP
get.newyork.weather.public.v1.resolvedb.net            # Weather for named location
get.40d7128_-74d0060.weather.public.v1.resolvedb.net   # Weather for coordinates

# WRONG: Implicit context (rejected or undefined behavior)
get.weather.public.v1.resolvedb.net             # Missing location: rejected
geoip.self.public.v1.resolvedb.net              # Source-IP inference: rejected

Privacy Best Practices

For applications handling sensitive data, ResolveDB supports multiple layers of protection:

LayerFeatureDescription
TransportDoH/DoTQuery authoritative servers via DNS-over-HTTPS to encrypt queries in transit
AuthenticationNamespace query tokensUse auth-rdbq... for private hosted-record reads
Payload encryptionAES-256-GCMClient-side encrypt data before storing; server never sees plaintext
Token privacyEncrypted transportUse DoH or DoT and redact the complete authenticated qname
Namespace isolationPrivate namespacesUse a customer-created namespace and its bound query token

Client-side encryption example:

// Encrypt before storing - server never sees plaintext
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);

// Base64-encode and store the ciphertext through the REST records API.
// Keep the encryption key and unique nonce outside ResolveDB.
Client                    DNS Resolver              ResolveDB Authoritative
   |                           |                              |
   |-- get.newyork.weather.public.v1.resolvedb.net ---------->|
   |                           |                              |
   |<-- TXT "v=rdb1;s=ok;d={"temp":72}" ---------------------|
   |                           |                              |
   |-- (may use resolver cache) |                              |

Write Operations

Important: Write operations are handled via the REST API at api.resolvedb.com, not via DNS queries. DNS is a read-optimized protocol; writes flow through the API.

Why API for Writes?

  • DNS queries are limited to 253 characters (FQDN limit)
  • DNS lacks reliable delivery guarantees for mutations
  • Authentication is simpler over HTTPS
  • Write confirmation requires bidirectional communication

API Examples

# Create a hosted namespace, then a record (`data` is base64)
curl -X POST https://api.resolvedb.com/api/v1/namespaces \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"namespace":{"name":"acme-catalog"}}'

curl -X POST https://api.resolvedb.com/api/v1/records \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"record":{"key":"config.acme-catalog.v1","data":"eyJ0aGVtZSI6ImRhcmsifQ==","content_type":"application/json","ttl_seconds":3600}}'

# Update or delete using the opaque `id` returned by create/list
curl -X PATCH https://api.resolvedb.com/api/v1/records/<record-id> \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"record":{"ttl_seconds":1800}}'
curl -X DELETE https://api.resolvedb.com/api/v1/records/<record-id> \
  -H "Authorization: Bearer <token>"

# List records in a namespace
curl 'https://api.resolvedb.com/api/v1/records?namespace=acme-catalog' \
  -H "Authorization: Bearer <token>"

After minting a namespace query token, the data is available through the private-record gate. Use DoH or DoT so the qname bearer is encrypted in transit:

curl -X POST https://api.resolvedb.com/api/v1/namespaces/<namespace-id>/query_tokens \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"name":"prod-reader","expires_in_days":30}'

RDBQ='rdbq...plaintext token returned above...'
dig +tls-ca +tls-hostname=dot.resolvedb.io @dot.resolvedb.io \
  TXT "get.auth-${RDBQ}.config.acme-catalog.v1.resolvedb.net" +short

Namespace Architecture

The namespace is one DNS label in the standard UQRP name. public selects the public service surface; any other non-reserved value identifies a hosted namespace. There is no separate user.resolvedb.<tld> DNS hierarchy.

Public Namespace (public.resolvedb.<tld>)

Globally accessible data through standardized interfaces.

<operation>.<params>.<resource>.public.<version>.resolvedb.<tld>

Examples:
get.london.weather.public.v1.resolvedb.net
get.AAPL.stock.public.v1.resolvedb.net
geoip.ip-8-8-8-8.public.v1.resolvedb.net

Hosted Namespaces

Hosted records use a globally unique, human-readable namespace in the standard UQRP query form:

<operation>.<params>.<resource>.<namespace>.<version>.resolvedb.<tld>

get.auth-rdbq<52>.config.acme-catalog.v1.resolvedb.net

The customer API also assigns each namespace an opaque UUID. That UUID is an API resource identifier only; it is not a DNS alias and never enters the sync wire format. Namespace names are immutable through the current public API.

Hosted namespaces are private by default and require an opaque rdbq query token in the qname. Operator-managed public_read namespaces are the explicit exception and are served tokenlessly.

Namespace Registration

Naming Rules

RuleConstraint
Length3-32 characters
CharactersLowercase a-z, 0-9, - (hyphen)
StartMust start with a letter
EndMust end with a letter or number
UniquenessGlobally unique, enforced case-insensitively

Reserved Namespaces

The customer API rejects the following exact names:

public system admin api www mail ftp
apple google microsoft amazon facebook meta twitter
github gitlab bitbucket
resolvedb dns nameserver ns ns01 ns02 ns03
test demo example staging production
root localhost internal private
hooli hooli-staging hooli-dev

Creating a Namespace

Namespaces are created via the customer API. The name is globally unique and the request body uses the standard Rails resource envelope:

curl -X POST https://api.resolvedb.com/api/v1/namespaces \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{"namespace":{"name":"acme-catalog"}}'

The response returns the server-generated namespace ID used by subsequent API requests:

{
  "id": "7ab60a06-2d2b-43d9-a639-622409965284",
  "name": "acme-catalog",
  "records_count": 0,
  "created_at": "2026-08-26T12:00:00.000Z",
  "updated_at": "2026-08-26T12:00:00.000Z"
}

Namespace renaming is not exposed by the current API. Delete and recreate a namespace only when its records and query tokens can also be replaced.

Access Control Model

Customer API access is authorized by customer JWTs or scoped API keys. DNS reads from private hosted namespaces require a namespace query token issued by the API. Public services and operator-managed public_read namespaces are tokenless. ResolveDB does not publish DNS _acl or namespace-claim records.

Query Format

Structure

<operation>.<params>.<resource>.<namespace>.<version>.resolvedb.<tld>
ComponentRequiredDescription
operationYesAction to perform
paramsNoEncoded parameters
resourceYesData resource name
namespaceYespublic or a globally unique hosted namespace
versionYesProtocol version (v1)
resolvedbYesProtocol marker
tldYes.net in the production deployment; additional TLDs are planned

Formal Grammar (ABNF)

; Query structure
query         = operation "." [auth-param "."] [params "."] resource "." namespace "." version ".resolvedb." tld
operation     = "get" / "info" / "geoip" / "manifest" / "identity"
params        = encoded-param *("." encoded-param)

; Parameter encodings (all use hyphen separators, NOT colons)
encoded-param = plain-param / b64-param / b32-param / hex-param / latlon

; Plain parameters: alphanumeric, cannot start/end with hyphen (RFC 1035)
plain-param   = ALPHANUM *61(ALPHA / DIGIT / "-") [ALPHANUM]
ALPHANUM      = ALPHA / DIGIT

; Encoding prefixes
b64-param     = "b64-" 1*base64url
b32-param     = "b32-" 1*base32                    ; Case-insensitive encoding
hex-param     = "hex-" 1*HEXDIG
auth-param    = "auth-rdbq" 52base32hex             ; Opaque namespace query token

; Self-parsed public-service params (the parser keeps these labels intact and
; the service decodes them; see the Units / Sun / Moon service sections).
; 'd' = decimal point, leading 'n' = negative sign, '_' separates lat/lon.
units-param   = number "-" unit "-to-" unit        ; e.g., 100-c-to-f, n40-c-to-f
number        = ["n"] (1*DIGIT ["d" 1*DIGIT] / "d" 1*DIGIT)  ; n=neg, d=decimal
unit          = 1*8(ALPHA / DIGIT)                  ; closed-table slug (c, km, mph, …)
sun-loc       = label / latlon                         ; weather location grammar
latlon        = signed-coord "_" signed-coord       ; e.g., 51d4769_-0d0005
signed-coord  = ["-"] 1*3DIGIT ["d" 1*DIGIT]
moon-date     = 4DIGIT "-" 2DIGIT "-" 2DIGIT        ; strict YYYY-MM-DD (UTC)

; Structural elements
resource      = label
namespace     = label
version       = "v" 1*DIGIT
tld           = "net"

; Labels per RFC 1035: alphanumeric start/end, max 63 chars
label         = ALPHANUM *61(ALPHA / DIGIT / "-") [ALPHANUM]

; Character classes
base64url     = ALPHA / DIGIT / "-" / "_"          ; URL-safe, no padding
base32        = %x41-5A / "2" / "3" / "4" / "5" / "6" / "7"  ; A-Z, 2-7
base32hex     = DIGIT / %x61-76                    ; lowercase 0-9, a-v

Grammar Notes:

  • All prefixes use hyphens (-), never colons (:) - colons are invalid in DNS labels per RFC 1035
  • Labels MUST start and end with alphanumeric characters (RFC 1035 Section 2.3.1)
  • Production DNS authorization accepts only the opaque auth-rdbq... token form
  • For private parameterized reads, auth-rdbq... MUST be the first params label. It authorizes the query but is omitted from the storage key; subsequent params labels remain part of the key.
  • A label beginning with auth- MUST be parsed only as the optional leading auth-param, never as plain-param; credential-shaped labels in any later position are invalid.

Operations

OperationDescriptionAuth RequiredTransport
getRetrieve dataNo (public) / Yes (user)DNS
infoResource metadata and JSON Schema (Schema Access)NoDNS + HTTP
geoipExplicit-IP geolocationNoDNS
manifestDataset manifestNoDNS
identityDataset identity factNoDNS

Writes (put and delete) use the HTTP API and are not DNS operations.

Parameter Encoding

Parameters requiring special characters are encoded using DNS-safe prefixes. All prefixes use hyphens (-) as separators since colons are not valid in DNS labels per RFC 1035.

PrefixEncodingUse Case
(none)Plain alphanumericSimple keys
b64-Base64 URL-safeJSON, binary, complex params
b32-Base32Case-insensitive
hex-HexadecimalBinary hashes
auth-Opaque rdbq query tokenPrivate hosted-record authorization

Security Token Prefixes [PLANNED]

The following BDT, CTP, and namespace-signature designs are not wired into the production DNS request path. They remain design material only and MUST NOT be used as a shipped client contract.

Blind Device Token (bdt-)

Provides device identity without exposing device IDs in queries. Used for IoT and industrial configurations.

Token Derivation:

device_secret = HKDF-SHA256(
    ikm  = factory_master_secret,
    salt = device_id,
    info = "resolvedb-bdt-v1"
)
blind_token = hex(SHA256(device_secret || factory_id || epoch_week)[0:16])

Query Format:

get.bdt-<32-hex-chars>.config.<factory-namespace>.v1.resolvedb.<tld>

Example (the 00000000 prefix marks the seeded demo token; production tokens are full 128-bit hashes):

get.bdt-00000000a7f3b2c4e8d9f012a7f3b2c4.config.hooli.v1.resolvedb.net

Validation:

  1. Server maintains index of blind_token β†’ device_id mappings
  2. Accepts tokens for current week AND previous week (seamless rotation)
  3. Returns E018 (bdtinvalid) for unknown tokens

Response Encryption: Responses MAY be encrypted with the device's derived secret:

v=rdb1;s=ok;t=data;e=aes;f=json;ttl=300;d=<AES-256-GCM(device_secret, config)>

Security Properties:

PropertyGuarantee
Device enumeration resistance2^128 token space
Identity privacyDevice ID never in query
RotationAutomatic weekly (epoch_week)
Factory isolationToken bound to factory_id

Cohort Token Pattern (ctp-)

Enables server-side user targeting without exposing user identity or targeting rules in queries.

Token Structure:

cohort_token = base64url(AES-256-GCM(
    key   = app_secret,
    nonce = random(12),
    data  = CBOR({
        "u": SHA256(user_id)[0:8],      // 8-byte user hash
        "s": segment_bitmap,             // 4-byte bitmap (32 targeting bits)
        "t": floor(unix_time / 300)      // 5-minute bucket
    })
))

Segment Bitmap (32 bits):

Bit 0:  is_premium         Bit 16: experiment_a
Bit 1:  is_beta_user       Bit 17: experiment_b
Bit 2:  is_internal        Bit 18: experiment_c
Bit 3:  (reserved)         Bit 19: experiment_d
Bit 4:  platform_ios       Bit 20-23: (reserved)
Bit 5:  platform_android   Bit 24: locale_en
Bit 6:  platform_web       Bit 25: locale_es
Bit 7:  platform_desktop   Bit 26: locale_fr
Bit 8:  region_na          Bit 27: locale_de
Bit 9:  region_eu          Bit 28: locale_ja
Bit 10: region_apac        Bit 29: locale_zh
Bit 11: region_latam       Bit 30: locale_pt
Bit 12-15: tier (0-15)     Bit 31: custom_flag

Query Format:

get.ctp-<base64url-token>.<resource>.<namespace>.v1.resolvedb.<tld>

Example:

get.ctp-dGVzdHRva2VuMTIzNDU2Nzg5MGFiY2RlZg.dark-mode.flags.hooli.v1.resolvedb.net

Validation:

  1. Server decrypts token with app's registered secret
  2. Validates timestamp (reject if >5 minutes old)
  3. Evaluates targeting rules against segment bitmap
  4. Returns evaluated flag values, NOT targeting rules

Security Properties:

PropertyGuarantee
User identity privacyOnly 8-byte hash in encrypted token
Targeting rule privacyRules evaluated server-side
Cache efficiencySame cohort (bitmap) = same cache entry
Replay window5-minute token expiry

Error Codes:

CodeStatusDescription
E019secviolCTP token decryption failed
E020secviolCTP token expired (>5 min)

Namespace-Bound Signature (sig-)

Cryptographically binds queries to a specific tenant namespace, preventing cross-tenant access even with stolen tokens.

Signature Derivation:

timestamp = unix_epoch_seconds()
material = UTF8(operation + "." + resource + "." + namespace + ".v1|" + timestamp + "|" + tenant_id)
signature = hex(HMAC-SHA256(tenant_query_key, material)[0:8])

Query Format:

get.sig-<16-hex-chars>-t-<unix-timestamp>.<resource>.<namespace>.v1.resolvedb.<tld>

Example (the 00000000 prefix marks the seeded demo signature):

get.sig-00000000a3f2e8c1d4b5a678-t-1704067200.config.hooli.v1.resolvedb.net

Validation:

  1. Extract namespace from query FQDN
  2. Look up tenant's tenant_query_key by namespace
  3. Recompute expected signature using extracted timestamp
  4. Constant-time compare signatures
  5. Verify timestamp within 5-minute window
  6. Return E018 (siginvalid) for any failure

Combined with JWT (Defense in Depth): For maximum security, combine signature validation with JWT:

get.sig-<sig>-t-<ts>.auth-h-<jwt-hash>.<resource>.<namespace>.v1.resolvedb.net

Server verifies:

  1. JWT claims contain matching tenant field
  2. Query namespace matches JWT tenant
  3. Signature is valid for query namespace

Security Properties:

PropertyGuarantee
Cross-tenant preventionSignature cryptographically bound to namespace
Token theft resistanceAttacker needs query_key, not just JWT
Replay window5-minute timestamp validation
Bug immunityWorks even if authorization code has bugs

Error Codes:

CodeStatusDescription
E018secviolSignature validation failed
E021secviolTimestamp outside valid window
E022secviolNamespace mismatch (JWT vs query)

Namespace Query Token (auth-rdbq…)

Status: Implemented (record sync v1). An opaque bearer token that gates reads of private namespaces on resolvedb-core DNS nodes. Tokens are minted by the management API and replicated to every DNS node (as SHA-256 digests) over the internal record-sync feed β€” no shared signing keys are provisioned across nodes, and a ~300-byte JWT would not fit in a 63-byte DNS label.

Token Format:

rdbq<52 chars of [0-9a-v]> ; 4 + 52 = 56 chars total
  • Charset is lowercase base32hex, so the token survives case-insensitive DNS handling unchanged.
  • As a params label, auth- + 56 = 61 chars β€” within the 63-byte label limit (RFC 1035).

Issuance (management API, namespace owner only):

# Mint (plaintext returned exactly once; only the SHA-256 digest is stored)
curl -X POST https://api.resolvedb.com/api/v1/namespaces/:id/query_tokens \
  -H "Authorization: Bearer <jwt>" \
  -d '{"name":"prod-reader","expires_in_days":30}'   # max 365

# List (no plaintext) / revoke (propagates to DNS nodes in seconds)
curl https://api.resolvedb.com/api/v1/namespaces/:id/query_tokens
curl -X DELETE https://api.resolvedb.com/api/v1/namespaces/:id/query_tokens/:token_id

Query Format:

get.auth-rdbq<52>.{resource}.{namespace}.{version}.resolvedb.net
get.auth-rdbq<52>.{params...}.{resource}.{namespace}.{version}.resolvedb.net

Enforcement (nodes with record sync enabled):

  1. public namespace: no token required.
  2. Public-read namespaces: a namespace the operator has flagged public_read=true (synced via sync.v1, see below) is answered tokenless and UNMETERED, exactly like public. This is how the hooli demo namespaces are served once migrated off DEMO_SEED to API-managed records. The flag is operator-only (never customer-settable) and requires sync: a sync-disabled node cannot consult it (see the parity note). A fleet-wide kill-switch PUBLIC_READ_DISABLED=true neutralizes the public-read branch without a Rails round-trip.
  3. Demo namespaces (hooli, hooli-staging, hooli-dev, demo): answered only when the node runs with DEMO_SEED=true; REFUSED otherwise. This is the reversible safety net retained alongside (2) during the migration.
  4. Any other namespace: the query MUST carry an auth- token whose SHA-256 digest is synced, unexpired, and bound to that exact namespace. Any failure (missing/unknown/expired/revoked token, wrong namespace, unknown namespace) returns rcode REFUSED β€” deny by default, on both plain DNS and DoH.
  5. Namespace labels are ASCII-lowercased before matching; mixed-case queries behave identically to lowercase ones.
  6. Tokens are opaque to the server: whatever auth- value is presented is hashed and looked up; rdbq-shaped tokens are never parsed as JWTs.

public_read wire contract (sync.v1): the namespace payload on BOTH the event feed (namespace.upserted) and the snapshot serializer carries an additive boolean public_read (DB default false):

{ "name": "hooli", "customer_id": 42, "public_read": true }

It is #[serde(default)] on the core deserializers (absent β‡’ false, fail-closed for old/partial-rollout events), so a node populates its public-read set on its FIRST snapshot. A public_read flip to false, or a namespace.deleted, removes the namespace from the set (tokenless reads revoked).

Parity note (sync-disabled nodes): public-read is a sync-only capability. A node running WITHOUT sync (SYNC_URL/SYNC_TOKEN unset) has no synced namespace state and therefore serves ONLY public and β€” when DEMO_SEED=true β€” the hard-coded demo namespaces. It NEVER honors public_read; this is intentional and fail-closed.

Caching: authorized private-namespace answers are returned with TTL 0 so resolvers and intermediaries do not cache token-keyed answers. The token is part of the qname, so any cache key would include it regardless; TTL 0 removes the shared-cache replay window but cannot force every intermediary to discard a response immediately.

Residual risk (documented): qnames containing tokens appear in resolver logs. Mitigations: short-lived tokens (≀365 days, default 30), revocation propagated by the next sync poll, TTL 0, and DoH/DoT transport.

Storage Lifetime vs DNS Cache TTL (two distinct concepts)

Hosted records carry two independent, easily-confused notions of "time to live". Treat them separately:

ConceptFieldMeaningDefault
Storage lifetimeexpires_at (record metadata)How long the record EXISTS in the system and is served by the fleet. When set and reached, the record is hard-deleted and the deletion propagates as erasure to every node.Persistent β€” omitting it means the record NEVER auto-expires
DNS cache TTLDNS RR TTLHow long resolvers MAY cache the answer. The authoritative serving path computes this value; it never deletes the stored record.A sensible per-class default for public/stored answers; 0 for authenticated private-namespace answers
Envelope TTL hintttl= (in the rdb1 TXT payload), sourced from ttl_seconds for hosted recordsApplication-visible metadata describing the record's configured cache class. It is not the DNS RR TTL and clients MUST NOT use it to override the RR TTL.Per-record/default hint; it can remain nonzero inside a private answer whose effective DNS RR TTL is 0

Rules:

  • A stored record is persistent by default. Storage expiry is opt-in: supply an explicit expires_at (must be in the future) or expires_in seconds-from-now (0 clears expiry, making the record permanent again).
  • The configured ttl_seconds and rendered envelope ttl= are caching metadata only and MUST NEVER be used to derive storage lifetime. Setting a short hint does not and must not delete the underlying record.
  • Authenticated private-namespace answers keep an effective DNS RR TTL 0 (see Caching above) regardless of storage lifetime or the envelope hint. The two are orthogonal. A persistent record served under a token still has RR TTL 0 even when its TXT payload contains a nonzero ttl= field.

In sync.v1, expires_at is ISO8601-or-null; null means non-expiring. The DNS nodes lazily drop a synced record only when it carries a non-null expires_at whose time has passed β€” persistent (null) records are never expired.

Security Token Summary

PrefixUse CaseKey DerivationExpiryError Codes
bdt-IoT device identityHKDF from factory secretWeekly rotationE018
ctp-User targetingAES-256-GCM with app secret5 minutesE019, E020
sig-Multi-tenant authHMAC-SHA256 with tenant key5 minutesE018, E021, E022
auth-rdbq…Private namespace reads32 random bytes, base32hex; SHA-256 digest synced≀365 days, revocablercode REFUSED

RFC Conformance

All security token prefixes conform to:

  • RFC 1035: Labels ≀63 chars, FQDN ≀253 chars, valid chars [a-z0-9-]
  • RFC 4648: Base64url encoding for CTP tokens
  • RFC 5869: HKDF key derivation for BDT
  • RFC 5116: AEAD (AES-256-GCM) for CTP encryption
  • RFC 2104: HMAC for NBA signatures

Usage Metering (usage.v1)

Status: Implemented; OFF by default. A counting-only telemetry pipeline that tallies authenticated (PrivateOk) queries per customer for display on the management API. It is display-grade telemetry, not a billing input (see the estimated flag below): it never gates, throttles, or prices a query, and it can never slow or fail DNS resolution. It is the inverse direction of record sync β€” sync.v1 is API β†’ fleet; usage.v1 is fleet β†’ API.

Hot-path safety (HARD CONSTRAINT). Emission is best-effort and fully off the DNS resolution path. The core node enqueues one event onto a bounded in-memory channel via a non-blocking try_send; a full channel drops the event (and increments resolvedb_usage_events_dropped_total) rather than blocking. A background task drains the channel to Redis. If Redis is unreachable the meter is a no-op. Consequently the pipeline is lossy by design and counts are an estimate, not a guarantee.

What is counted. Only successful PrivateOk queries β€” a private namespace answered with β‰₯1 record by a valid auth-rdbq… query token. An authenticated miss/NODATA, lazy-expired, or error is NOT counted (usage = authed hits only). Public, demo, refused, and unauthenticated queries are structurally un-meterable (no customer_id to attribute to) β€” this also denies an anonymous-flood attacker any way to inflate a customer's count. Note the divergence from query_stats.v1: an authenticated miss STILL records a tenant miss row there (analytics wants it). stats = all authed (any status); usage = authed hits only.

Transport (core β†’ API). Core XADDs each event to a Redis stream (STREAM_KEY = "resolvedb:usage", MAXLEN ~ 100000); Rails consumes it with XREAD from a Postgres-persisted high-watermark. Stream event fields:

type=authed_query                  # constant discriminator
customer_id=<i64>                  # namespace owner at emit time (point-in-time attribution)
token_id=<sha256-hex>              # the authorizing query token's SHA-256 DIGEST (non-secret)
namespace=<lowercase-label>
ts=<unix-seconds>
transport=dns|doh

Privacy invariant (enforced by tests). The event carries ONLY the closed-set attribution tuple above. It NEVER carries the raw token, the qname, query params, or the client IP. token_id is the same stable SHA-256 digest the fleet already syncs for token matching β€” knowing it does not let anyone make an authenticated query (the plaintext token, which lives only in the qname, is required for that).

Aggregation (API side). Counts are additive (one raw event per query). Rails resolves token_id through an immutable, Rails-only attribution tombstone created in the same transaction as the query token. This keeps the event bound to its mint-time customer and organization after the token or namespace is deleted; organization_id never enters sync.v1 or usage.v1. Unknown, empty, or customer-mismatched digests are skipped rather than creating unattributed tenant rows. The consumer folds each accepted event +1 into usage_counters, bucketed by period (hour / day / billing-month) per (customer, organization, namespace, token, transport). Idempotency is the monotonic Redis stream-id high-watermark (usage_ingest_cursors), advanced in the same DB transaction as the increments: an at-least-once redelivery is below the watermark and dropped, so it is never double-counted. An event for a customer_id not yet known locally (sync lag) is deferred β€” the watermark is held so it is retried on a later tick, not silently skipped past. Rows are pruned past USAGE_RETENTION_DAYS.

Read surface. GET /api/v1/usage gains a metered object, scoped by Pundit to the active organization when organizations are enabled and otherwise to the calling customer's own customer_id:

"metered": {
  "since": "...", "until": "...",
  "estimated": true,                       // reflects the lossy hot path above
  "total_authenticated_queries": 4210,
  "by_namespace": [ { "name": "acme", "count": 4210 } ],
  "tier": "free",
  "tier_allowance": 100000                 // read-only display; no price, no enforcement
}

tier_allowance is the per-tier included allowance from configuration (x.resolvedb.metering_allowance); it is informational only and gates nothing. Stripe billing (now implemented, OFF by default) adds a soft_cap_state block to this metered object for an "approaching / over your included queries" upgrade prompt, but it remains display-only β€” estimated: true is always present and no code path turns a counter into a charge, an invoice, or a query block. These counts are never a billing input.

Operational note. Both ends are gated off by default and share the resolvedb:usage stream key: core enables on USAGE_REDIS_URL; the Rails consumer enables on USAGE_INGEST_ENABLED + USAGE_REDIS_URL. See docs/runbooks/usage-metering-launch.md.

Query-Stats (query_stats.v1)

Status: Implemented; OFF by default. A per-query analytics pipeline that records one row per UQRP query (every product query, not just authenticated ones) for the dashboard Query Analytics page. Like usage.v1 it is the inverse direction of record sync (fleet β†’ API), display-grade, and can never slow or fail DNS resolution. It is the higher-volume sibling of usage.v1: usage metering counts only authenticated queries for billing display; query-stats counts every query for analytics.

Hot-path safety (HARD CONSTRAINT). Identical discipline to usage.v1: emission is a single non-blocking try_send onto a bounded channel (drop-on-full, resolvedb_query_stat_events_dropped_total), drained to Redis by a background task. No-op when QUERY_STAT_REDIS_URL is unset or RESOLVER_REGION is unset/unknown. A second always-present gauge resolvedb_query_stat_meter_enabled (0 disabled / 1 active) makes a silent disable (e.g. a per-host RESOLVER_REGION cleared by an ops change while Redis stays configured) alertable rather than invisible.

Single emit site. Both DNS callers (search and lookup) route through one handle_uqrp_lookup chokepoint, which emits exactly once β€” every UQRP query is counted exactly once (no under-count on lookup, no double-count on search). Apex/NS static queries never reach it and are never counted. The DoH transport keeps its own gate path and does not emit query-stats in v1 (out of scope; see the runbook).

Transport (core β†’ API). Core XADDs each event to a Redis stream (STREAM_KEY = "resolvedb:query_stats", MAXLEN ~ 1000000); Rails consumes it with XREAD from a Postgres-persisted high-watermark. Stream event fields:

type=query_stat                    # constant discriminator
resource=<closed class>            # weather|forecast|stock|forex|crypto|geoip|units|sun|moon|btc|records|schema
version=<[a-z0-9]{1,16}>           # charset-validated both sides ("v0" substituted on failure)
status=hit|miss|expired|error|refused  # closed outcome enum
region=nyc1|sfo3|ams3              # RESOLVER_REGION, validated against the closed set
ts=<unix-seconds>
customer_id=<i64>                  # PRESENT ONLY for PrivateOk (authenticated) queries
namespace=<[a-z0-9._-]{1,63}>      # PRESENT ONLY for PrivateOk; charset-bounded (else dropped)

Status mapping (closed, total). Set per-arm where the outcome is known: answered with β‰₯1 record β†’ hit; resolved-but-empty NODATA β†’ miss; lazy-expiry NODATA β†’ expired (emitted EXCLUSIVELY from the expiry arm, never conflated with ordinary NODATA); a gate/auth denial (REFUSED) β†’ refused (its OWN status, an expected outcome, set explicitly at the gate arm); genuine failure (ServFail / internal / unsupported rtype / service error) β†’ error. A UQRP parse error emits nothing (not a product query). refused and error are distinct: refused is an authz denial (deny-by-default, missing/invalid token), error is a true failure.

Deploy ordering (BLOCKING for the refused enum change). Rails skips an unrecognized status AND advances the ingest cursor past it (permanent silent drop). Therefore Rails (model inclusion + QueryStats::Ingestor::VALID_STATUSES) MUST accept refused and be confirmed consuming BEFORE the core fleet begins emitting it. Never deploy core ahead of Rails for this change β€” see docs/runbooks/query-stats-refused-launch.md.

Privacy invariant (enforced by tests). The event is a closed struct, structurally incapable of carrying the qname, query params, the raw or hashed token, or the client IP. resource is a closed class (unknown β†’ records, never an echo of the untrusted label). customer_id/namespace are sourced ONLY from the on-node MeterAttribution (PrivateOk, token-bound) and travel together β€” they are absent (NULL-tenant) for public/demo/refused/unauthenticated queries.

Ingest & tenancy (API side). Each event becomes one query_stats row (insert_all!, bounded batch). namespace_id is set ONLY when the event carries both customer_id and namespace, the customer exists locally, AND that exact namespace is owned by that customer_id (Namespace.find_by(name:, customer_id:)) β€” the proven cross-tenant guard. A forged numeric customer_id, an unowned namespace, or absent fields β†’ namespace_id = NULL. No defer path (unlike usage.v1): an unknown/unverifiable customer_id is inserted IMMEDIATELY as NULL-tenant, so one unknown-but-recent id can never head-of-line- block the high-volume watermark. Idempotency is the monotonic Redis stream-id high-watermark (query_stat_ingest_cursors), advanced in the same DB transaction as the inserts (at-least-once redelivery never double-inserts). Rows prune past QUERY_STATS_RETENTION_DAYS (default 30; hourly prune at fleet QPS). latency_ms is NULL in v1 (no hot-path timing).

Read surface. GET /api/v1/query_logs (Pundit-scoped to the caller's namespaces). The meta.total is a recent-window (24h) count β€” never an all-time scan over a table that grows to hundreds of millions of rows β€” surfaced with meta.total_window_hours.

Operational note. Both ends are gated off by default and share the resolvedb:query_stats stream key: core enables on QUERY_STAT_REDIS_URL (+ a known RESOLVER_REGION); the Rails consumer enables on QUERY_STATS_INGEST_ENABLED + QUERY_STATS_REDIS_URL. See docs/runbooks/query-stats-launch.md.

Dataset Registry (BIN/GTIN, datasets resource, v1)

Gated OFF by default (DATASETS_ENABLED). When off, the reserved datasets resource is REFUSED and dataset sync events are dropped.

datasets is globally reserved across every namespace and can never be used as a hosted-record resource. Non-public dataset queries are REFUSED even when they carry a valid hosted-namespace query token.

ResolveDB serves publisher-attested reference-data facts (BIN issuer lookups, GTIN identity) on the public free-tier read surface. ResolveDB is NOT a proprietary dataset vendor: every dataset carries a mandatory, surfaced license and provenance, and the differentiator is that facts are DNSSEC-signed and operator-attested and cacheable. Publishing and attestation are the gated/paid surface (in the Rails API); reads are public.

Legal guardrail. ResolveDB never ingests, scrapes, or redistributes Visa VBASS or GS1 GEPIR data. license + provenance are non-null on every dataset and shipped seed data is labeled SYNTHETIC.

Two signature layers (never conflated)

  1. DNSSEC RRSIG β€” DNS answer authenticity and integrity (the zone signer signs every answer, including dataset TXTs). It cannot carry attestation: a draft TXT would still be RRSIG-valid.
  2. Attestation signature β€” a payload-level Ed25519 signature over the canonical manifest tuple (below), in a SEPARATE trust domain from DNSSEC. It is produced ONLY in the Rails API (single chokepoint) and replicated to the DNS fleet as opaque bytes; the core never holds the attestation private key and (in MVP) does not self-verify β€” it stores and serves the bytes. Clients verify BOTH layers.

Canonical manifest (the signed envelope)

Byte-deterministic so the signer and any verifier agree:

canonical = "rdb-attest.v1\n"
          + "name="       + name        + "\n"
          + "version="    + version     + "\n"   // semver
          + "license="    + license     + "\n"   // SPDX id or free text, non-empty
          + "provenance=" + provenance  + "\n"   // non-empty
          + "sha256="     + sha256_hex_lowercase  // 64 lc hex of the bulk content

Field order is FIXED; values are NFC UTF-8 with \n and \ forbidden per field. sha256 binds the manifest to off-DNS bulk content (a CDN URL); clients fetch and re-hash out of band. The content URL is untrusted at fetch time β€” only the sha256 is authoritative. Signature = Ed25519(attestation_sk, canonical_bytes).

Query formats (single params label)

Both formats encode a compound key inside ONE DNS label, using hyphen sub-encoding (colons are illegal in DNS labels per RFC 1035). The -v- / -k- 3-byte infix markers are reserved and rejected inside slugs, keeping the compound-key split unambiguous.

# Manifest β€” latest version (alias)
dig TXT manifest.bin-acme.datasets.public.v1.resolvedb.net +short

# Manifest β€” pinned version (d -> . decode: 1d2d0 = 1.2.0)
dig TXT manifest.bin-acme-v-1d2d0.datasets.public.v1.resolvedb.net +short

# Identity β€” BIN (6-8 digits)
dig TXT identity.bin-acme-k-411111.datasets.public.v1.resolvedb.net +short

# Identity β€” GTIN (8/12/13/14 digits)
dig TXT identity.gtin-acme-k-00012345600012.datasets.public.v1.resolvedb.net +short
  • Slug = left of the -v-/-k- marker, 1–40 lowercase ASCII letters, digits, or hyphens; it cannot begin/end with - or contain the reserved -v-/-k- infixes. (-t- remains legal and is positionally disambiguated.)
  • Version decode: d β†’ ., then strict ^\d+\.\d+\.\d+$ semver (a manifest-local step, NOT weather coordinate decoding).
  • Item key charset/length is bounded by the slug-inferred kind (bin-* β‡’ 6–8 digits, gtin-* β‡’ 8/12/13/14 digits). The authoritative kind is the stored row's existence.
  • The complete params label, including marker, key/version, and optional as-of, must remain within DNS's 63-byte label limit. A slug valid by itself can still be too long for a particular identity or pinned-manifest form.

Response schemas (TXT)

Manifest (;-joined key=value, ASCII, 255-byte TXT chunking as needed):

v=rdb-attest.v1;ds=bin-acme;ver=1.2.0;lic=CC-BY-4.0;prov=SYNTHETIC-sample;
sha256=<64hex>;url=https://cdn.resolvedb.../bin-acme-1.2.0.jsonl;
att=attested;attsig=<base64 ed25519 sig>;attkid=ak1;atts=<unix-ts>

Only att=attested is ever served (unattested versions are never stored). Field bounds: ds ≀ 40, ver ≀ 16, lic ≀ 128, prov ≀ 256, url ≀ 2048, sha256 = 64 hex, attsig = base64 Ed25519, attkid ≀ 16. The rendered value must be ≀ 3500 bytes (the same MAX_RENDERED_VALUE_BYTES ceiling the sync writer enforces; oversized values are dropped, never stored).

Identity:

v=rdb-id.v1;ds=bin-acme;k=411111;issuer=Acme Bank;brand=visa;cc=US;type=credit

Per-item integrity = the manifest sha256 over the bulk content + DNSSEC + the row's existence implying its dataset is attested. Per-item attestation signatures are deferred to v2.

Client verification steps

  1. Validate the DNSSEC chain on the TXT answer (DNS answer authenticity and integrity).
  2. Rebuild the canonical manifest tuple from the served fields and verify attsig with the attestation public key for attkid. Public keys are distributed via the DNSSEC-signed well-known manifest.keys.datasets.public.v1.resolvedb.net TXT and the docs.
  3. Fetch the url bulk content out of band and confirm its SHA-256 equals sha256 (the URL is untrusted; only sha256 is authoritative).

Status / error mapping

ConditionResponse
resource=datasets with op βˆ‰ , or namespace β‰  public, or version β‰  v1REFUSED
Malformed slug / version / item keyFormErr
Well-formed but unknown slug / version / keynegative (NODATA β€” this zone never returns raw NXDOMAIN by design)
Attested manifest / identity presentTXT answer (+ RRSIG) carrying attsig, license, provenance
DATASETS_ENABLED offREFUSED

Reserved storage prefixes

manifest. and identity. (with resource datasets, namespace public, version v1) are RESERVED storage-key prefixes. Customers cannot create a public-namespace record whose rendered key would begin with them. Dataset slugs are globally unique, so manifest.<slug>.…/identity.<slug>.… keys are partitioned per publisher by construction β€” a dataset write can never poison a public-service key or another publisher's data.

Temporal validity & as-of queries (identity facts only)

Gated OFF by default behind TEMPORAL_FACTS_ENABLED, nested under DATASETS_ENABLED (both ends must be on). When off, -t- is REFUSED at parse (FormErr) and identity replicates as a single current fact exactly as before. Manifests are immutable by sha256 and have NO temporal form. Records-side temporal is deferred (no attestation anchor for the long-TTL rule).

Each identity item (a single BIN/GTIN) may carry one or more validity windows, each a half-open interval [valid_from, valid_to). An as-of query selects the single window containing the as-of instant:

# Current identity (the window containing "now")
dig TXT identity.bin-acme-k-411111.datasets.public.v1.resolvedb.net +short

# As-of by unix seconds
dig TXT identity.bin-acme-k-411111-t-1500000000.datasets.public.v1.resolvedb.net +short

# As-of by calendar date (YYYY-MM-DD, interpreted at 00:00:00Z)
dig TXT identity.bin-acme-k-411111-t-2023-11-14.datasets.public.v1.resolvedb.net +short
  • The as-of token rides on the LAST -t- infix of the identity params label (unambiguous: item keys are pure digits and slugs cannot end in -). It is ONLY accepted for identity; manifests reject it.
  • asof is EITHER unix seconds (1–10 digits, 0 ≀ v ≀ 253402300799) OR an exact YYYY-MM-DD calendar date at midnight UTC. Anything else β‡’ FormErr with a single, non-differentiated negative answer (no oracle).
  • Selection is half-open: valid_from <= asof < valid_to. Absent valid_from = βˆ’βˆž, absent valid_to = current/open. No -t- β‡’ asof = now (server clock). The server never serves a window with valid_from > asof (no future disclosure) and never widens beyond attested data.
  • Windows for one item never overlap (enforced in the Rails API by a per-item advisory lock + a Postgres EXCLUDE constraint). A residual ambiguity fails closed (NODATA).

TTL rule (settled-past β‡’ immutable). A selected window whose valid_to is finite AND strictly in the settled past (valid_to < now βˆ’ 3600s skew margin) is provably immutable and served with the long TTL_IMMUTABLE (7 days). A current/open window, or one that ended within the skew margin, is served with the short TTL_STANDARD (1 hour). The server clock is the only time source; a client-supplied as-of never widens the TTL.

Storage shape (sync writer ↔ serving dispatch, both in core).

# Per-item temporal index (TXT VALUE; ';'/',' allowed like the manifest envelope)
identity.<slug>.<item-key>.tindex.datasets.public.v1
  v=rdb-tindex.v1;ds=<slug>;k=<item>;w=<vf>,<vt>;w=…
  (vf ∈ {ninf, unix-digits}; vt ∈ {cur, unix-digits}; ≀ 64 windows)

# Per-window fact record (the existing rdb-id.v1 envelope, verbatim)
identity.<slug>.<item-key>.t.<vf-token>.datasets.public.v1
  (vf-token = "ninf" | unix-digits)

Serving is deterministic, two retrievals, no range scan: retrieve the tindex, select the unique window for the as-of instant, then retrieve that window's fact key. A tindex miss falls back to the legacy single key (a pre-temporal item).

Stock Service β€” Exchange Reference Provenance (stock)

The stock resource returns a real-time US quote for a ticker. The live price object is venue-anonymous (the upstream snapshot does not state which venue a trade executed on). Separately, ResolveDB can surface the ticker's listed primary exchange as ISO-10383 MIC, drawn from a STATIC, DATED reference snapshot embedded in the server.

These two facts are NEVER conflated. The exchange fields are honest reference-data provenance with an as-of and a source β€” they answer "what is the listed primary exchange for this ticker per a dated reference snapshot," not "this price executed on this venue."

Query forms

# Bare ticker (price; provenance attached when fresh + known)
get.AAPL.stock.public.v1.resolvedb.net

# Pin an explicit exchange MIC with the `-x-<MIC>` infix on the ticker label
get.AAPL-x-XNAS.stock.public.v1.resolvedb.net

The MIC is a fixed 4-letter ISO-10383 code from a closed allowlist: XNAS, XNYS, XASE, ARCX, BATS, IEXG. -x- is recognized only by the finance validator (the UQRP parser keeps the single stock params label intact; no generic decode). Non-US venues are not supported in v1.

Response fields (TXT, v=rdb1;s=ok;t=data)

Base fields: sym, prc, chg, pct, vol, opn, hi, lo (plus optional extended h52/l52/pe/div).

Exchange provenance is an all-three-or-none triple, emitted right after sym and before prc:

FieldMeaning
exchrefISO-10383 MIC of the LISTED primary exchange (reference, not venue)
exchsrcProvenance tag of the reference dataset (synthetic-ref in the shipped fixture)
exchasofUTC date (YYYY-MM-DD) of the reference snapshot, so staleness is legible
v=rdb1;s=ok;t=data;ttl=60;ts=1704067200;sym=AAPL;exchref=XNAS;exchsrc=synthetic-ref;exchasof=2026-06-01;prc=189.95;chg=2.34;pct=1.25;vol=52300000;opn=187.50;hi=191.05;lo=186.82

Provenance is surfaced ONLY when the snapshot is fresh (within MAX_REFERENCE_AGE_DAYS, default 120) AND the ticker is known to the reference map. When the snapshot is stale, the ticker is unknown, or the dataset is empty, the price still serves but bare (no exch* fields) β€” degrade, never assert a venue from stale/missing data.

Error contract

A bare ticker never fails on the exchange dimension. An explicit -x-<MIC> query that cannot be served β€” unsupported/malformed MIC, ticker unknown to the reference map, listed exchange β‰  requested MIC, or a stale reference snapshot β€” returns one uniform fail-closed outcome: ExchangeUnavailable β†’ FormErr (E005). The four cases collapse to a single DNS code on purpose, so a client cannot use a NODATA-vs-FormErr difference to enumerate which tickers exist in the reference map. Over UDP/TCP DNS this is (intentionally) indistinguishable from any other invalid-input FormErr; the sanitized d=Exchange unavailable detail reaches only /query HTTP and DoH-with-body, and never echoes the requested MIC.

Dataset provenance & licensing

The embedded data/ticker_mic.csv ships SYNTHETIC (hand-authored placeholders) by default. Embedding REAL Polygon/Massive-derived ticker→MIC reference data is a blocking operator ToS sign-off (mirrors the dataset- registry VBASS/GEPIR guardrail): confirm in writing that the provider terms permit embedding + redistributing the derived dataset in the (private) binary before swapping in real data. With the dataset absent/empty the feature degrades to price-only and explicit -x- queries fail closed. See docs/runbooks/stock-exchange-launch.md.

Query Examples

# With location param (plain alphanumeric)
get.newyork.weather.public.v1.resolvedb.net

# Base64 encoded params (JSON with special chars)
# {"lat":40.7128} -> eyJsYXQiOjQwLjcxMjh9
get.b64-eyJsYXQiOjQwLjcxMjh9.location.public.v1.resolvedb.net

# Authenticated private hosted record (opaque namespace token)
get.auth-rdbq<52>.config.acme-catalog.v1.resolvedb.net

# GeoIP lookup (explicit IP required)
geoip.ip-8-8-8-8.public.v1.resolvedb.net

Units Service (units)

The units resource is a pure-compute, tokenless, unmetered, cacheable public.v1 service that converts a numeric value between two units in the same category. There is no external provider and no network: every conversion factor is a mathematical constant authored in the server, so the answer for a given query never changes (it caches as stable, TTL 86400).

Query format

get.<value>-<from>-to-<to>.units.public.v1.resolvedb.net

The whole expression is a single params label that the service parses itself (the UQRP parser keeps it intact; no generic decode). It splits on the literal -to- separator: everything before is <value>-<from>, everything after is <to>. The value is the token before the last - of the left-hand side, so a value token may contain no -.

Value encoding (DNS-safe, colons/dots/signs invalid in labels):

  • d is the decimal point (1d5 = 1.5, 0d001 = 0.001, d5 = 0.5).
  • A single leading n is a negative sign (n40 = -40).
  • Only digits and one d may follow; exponents, embedded signs, and whitespace are rejected. The value is parsed to f64 and any NaN/Inf/overflow is rejected.

Unit slugs are a closed table across five categories. Conversion is only valid within one category; a cross-category pair is an error.

CategoryBaseSlugs
temperaturekelvin (affine)c (celsius), f (fahrenheit), k (kelvin)
lengthmetrem, km, cm, mm, mi (mile), yd (yard), ft (foot), in (inch)
masskilogramkg, g, mg, t (tonne), lb (pound), oz (ounce)
volumelitre (US customary)l, ml, gal (us-gallon), qt (us-quart), floz (us-fluid-ounce)
speedmetre/secondms (m/s), kmh (km/h), mph (mi/h), kn (knot)

Response fields (TXT, plain)

The response body is a flat key=value string served directly without a v=rdb1 envelope. Results are rounded to 6 significant figures and rendered without trailing zeros (so 212, not 212.0000).

FieldMeaningExample
inInput value as parsed100
fromCanonical name of the source unitcelsius
toCanonical name of the target unitfahrenheit
rConversion result (6 sig figs, trimmed)212
catUnit categorytemperature
# 100 C -> F
dig TXT get.100-c-to-f.units.public.v1.resolvedb.net +short
# "in=100;from=celsius;to=fahrenheit;r=212;cat=temperature"

# -40 C -> F (negative via leading n)
dig TXT get.n40-c-to-f.units.public.v1.resolvedb.net +short
# "in=-40;from=celsius;to=fahrenheit;r=-40;cat=temperature"

# 5 km -> mi
dig TXT get.5-km-to-mi.units.public.v1.resolvedb.net +short
# "in=5;from=kilometre;to=mile;r=3.10686;cat=length"

# 60 mph -> km/h
dig TXT get.60-mph-to-kmh.units.public.v1.resolvedb.net +short
# "in=60;from=mile-per-hour;to=kilometre-per-hour;r=96.5606;cat=speed"

Error contract

ConditionOutcome
Missing/empty params, no -to-, empty value/unit, overlong label (>64)FormErr
Value not parseable / NaN / Inf / exponent / stray signFormErr
Unit slug not in the closed tableFormErr
from and to valid but in different categoriesFormErr

All malformed/cross-category inputs collapse to a single FormErr DNS code (no panic, no enumeration oracle). A non-get operation, a non-public namespace, or a non-v1 version is rejected by the generic dispatch before the service runs. Only TXT is answered; other record types return NODATA.

Sun Service (sun)

The sun resource is a pure-compute, tokenless, unmetered, cacheable public.v1 service returning sunrise / sunset / solar-noon / civil-dawn / civil-dusk and day length for a location on the current UTC day. The math is the public-domain NOAA / Meeus low-precision solar-position model; no external provider is consulted (location resolution may geocode a city name β€” see below).

Query format

get.<location>.sun.public.v1.resolvedb.net

<location> reuses the weather service location grammar exactly:

  • City name: get.london.sun.public.v1.resolvedb.net
  • Coordinates <lat>_<lon> with d as the decimal point and _ separating latitude/longitude (leading - allowed for negatives): get.51d4769_-0d0005.sun.public.v1.resolvedb.net
  • By IP: get.ip-8-8-8-8.sun.public.v1.resolvedb.net
  • By what3words (hyphens replace dots): get.w3w-filled-count-soap.sun.public.v1.resolvedb.net

Response fields (TXT, plain)

Times are ISO-8601 UTC instants (YYYY-MM-DDTHH:MM:SSZ); day length is XhYm.

FieldMeaningExample
riseSunrise (UTC)2024-06-21T03:43:00Z
setSunset (UTC)2024-06-21T20:21:00Z
noonSolar noon (UTC) β€” always present2024-06-21T12:02:00Z
dawnCivil dawn (sun at -6Β°)2024-06-21T02:45:00Z
duskCivil dusk (sun at -6Β°)2024-06-21T21:19:00Z
daylenDay length (set βˆ’ rise)16h38m

Polar edge cases: at high latitudes a day may have no sunrise/sunset. In that case the response carries polar=day (midnight sun) or polar=night (polar night) instead of rise/set, with daylen=24h0m or daylen=0h0m respectively. Solar noon is always defined; dawn/dusk are omitted when twilight does not occur.

# London (summer solstice example output)
dig TXT get.london.sun.public.v1.resolvedb.net +short
# "rise=2024-06-21T03:43:00Z;set=2024-06-21T20:21:00Z;noon=...;dawn=...;dusk=...;daylen=16h38m"

# By coordinates (lat_lon, d=decimal point)
dig TXT get.51d4769_-0d0005.sun.public.v1.resolvedb.net +short

# Polar night (high northern latitude in winter)
# "polar=night;noon=...;daylen=0h0m"

Error contract

ConditionOutcome
Missing/empty paramsFormErr
Invalid coordinates / invalid input / private IPFormErr
City not foundNODATA
Backend geocode failureServFail

Only TXT is answered. Non-get/non-public/non-v1 are rejected by the generic dispatch before the service runs. The result caches for 1 hour (TTL_OP_SUN) so it rolls over to the next day's events without serving stale times.

Moon Service (moon)

The moon resource is a pure-compute, tokenless, unmetered, cacheable public.v1 service returning the lunar phase, illuminated fraction, age, and the next full/new-moon dates. It is location-independent. The math is a low-precision Meeus-style approximation from the mean synodic month and a J2000 reference new moon (accurate to well under a day for phase naming).

Query format

get.moon.public.v1.resolvedb.net               # today (current UTC date)
get.<YYYY-MM-DD>.moon.public.v1.resolvedb.net  # a specific UTC date

The optional date is a single params label with literal hyphens. It is validated strictly as a real calendar date (exactly 10 chars, YYYY-MM-DD, leap-year and month-length aware); an impossible or malformed date is rejected. With no params label the service computes for the current UTC date.

Response fields (TXT, plain)

FieldMeaningExample
phasePhase name (one of the 8 canonical phases)Waxing Gibbous
illumIlluminated fraction of the disc, 0.000..1.0000.787
ageAge in days since the last new moon (1 decimal)10.3
next_fullDate of the next full moon (UTC, YYYY-MM-DD)2024-03-25
next_newDate of the next new moon (UTC, YYYY-MM-DD)2024-04-08

Phase names: New Moon, Waxing Crescent, First Quarter, Waxing Gibbous, Full Moon, Waning Gibbous, Last Quarter, Waning Crescent.

# Today
dig TXT get.moon.public.v1.resolvedb.net +short
# "phase=Waxing Gibbous;illum=0.812;age=11.3;next_full=...;next_new=..."

# A specific date
dig TXT get.2024-03-20.moon.public.v1.resolvedb.net +short
# "phase=Waxing Gibbous;illum=0.787;age=10.3;next_full=2024-03-25;next_new=2024-04-08"

Error contract

ConditionOutcome
Malformed / impossible date label (e.g. 2024-02-30, 2024-1-1)FormErr

No params (today) and a valid date both succeed; computation never fails. Only TXT is answered. Non-get/non-public/non-v1 are rejected by the generic dispatch. The result caches for 1 hour (TTL_OP_MOON).

Reserved resource β€” btc (NOT yet public). A btc chain-stats resource (get.<metric>.btc.public.v1.resolvedb.net, metrics height/fees/ mempool/halving/difficulty) exists in the reference implementation but ships gated OFF behind BTC_SERVICE_ENABLED (default false). While off, the resource behaves as unknown (NODATA) and leaks nothing. Even when enabled it currently serves mock data β€” every response is tagged src=mock β€” pending a self-hosted Esplora/bitcoind upstream. It is therefore documented here only as reserved; its query grammar and fields are not yet a stable public contract and may change before launch.

Schema Access (info Operation)

The info operation provides resource metadata and schema definitions in JSON Schema format. Schemas enable:

  • LLM-friendly introspection: Rich descriptions, examples, and actionable field documentation
  • Client validation: JSON Schema for validating responses
  • API discovery: List available resources per namespace

DNS Query Format

info.<resource>.<namespace>.<version>.resolvedb.<tld>

Examples:

# Get weather service schema
dig TXT info.weather.public.v1.resolvedb.net +short

# Get GeoIP service schema
dig TXT info.geoip.public.v1.resolvedb.net +short

HTTP Endpoint

GET /schema?q=<query>

The q parameter accepts any UQRP query format. The parser extracts resource.namespace.version, ignoring operation and parameters. This allows copy-pasting real queries to discover their schema:

# Get schema for a resource
curl 'https://doh.resolvedb.io/schema?q=weather.public.v1.resolvedb.net'

# Same result - operation and params ignored
curl 'https://doh.resolvedb.io/schema?q=get.seattle.weather.public.v1.resolvedb.net'

# Namespace listing (no resource specified)
curl 'https://doh.resolvedb.io/schema?q=public.v1.resolvedb.net'

Response Format (JSON Schema)

{
  "status": "ok",
  "version": "rdb1",
  "namespace": "public",
  "resource": "weather",
  "schema": {
    "$schema": "https://json-schema.org/draft/2020-12/schema",
    "$id": "https://resolvedb.net/schema/public/weather/v1",
    "title": "Weather Schema",
    "description": "Weather data for a location",
    "type": "object",
    "additionalProperties": false,
    "properties": {
      "tc": {
        "type": "number",
        "description": "Temperature in Celsius. Use for metric regions.",
        "example": 22.5
      },
      "tf": {
        "type": "number",
        "description": "Temperature in Fahrenheit. Use for US/Imperial regions.",
        "example": 72.5
      },
      "cnd": {
        "type": "string",
        "description": "Current weather condition. Use for display or weather icons.",
        "enum": ["clear", "cloudy", "rain", "snow", "fog"]
      }
    },
    "required": ["tc", "tf", "cnd"]
  },
  "meta": {
    "auth_required": false,
    "rate_limit_tier": "standard",
    "default_ttl": 300
  },
  "dns_format": {
    "query_template": "get.<city>.weather.public.v1.resolvedb.net",
    "placeholders": {
      "city": {"type": "string", "examples": ["seattle", "london", "tokyo"]}
    },
    "example_response": "v=rdb1;s=ok;t=data;f=json;tc=22.5;tf=72.5;cnd=clear"
  },
  "http_format": {
    "endpoint": "GET /resolve?name=get.seattle.weather.public.v1.resolvedb.net&type=TXT",
    "curl_example": "curl 'https://doh.resolvedb.io/resolve?name=get.seattle.weather.public.v1.resolvedb.net&type=TXT'",
    "schema_endpoint": "GET /schema?q=weather.public.v1.resolvedb.net"
  },
  "error_responses": [
    {"status": "notfound", "code": "E004", "description": "City not found"},
    {"status": "ratelimit", "code": "E010", "description": "Rate limit exceeded", "retry_after": true}
  ]
}

DNS Response (t=data)

For DNS responses, schemas use t=data response type with f=json:

v=rdb1;s=ok;t=data;f=json;ttl=3600;d={"$id":"weather.public.v1",...}

DNS TXT RDATA is split into ordered character strings of at most 255 bytes. Clients concatenate those strings before parsing. Schema JSON larger than 3,500 bytes is not served over DNS and returns SERVFAIL; use the HTTP /schema endpoint instead. There is no multi-record schema chunk protocol.

Access Control

NamespaceAuth RequiredNotes
publicNoAll public schemas freely accessible
<hosted namespace>Not exposedProduction rejects non-public schema lookup

HTTP error response for private namespaces:

{"status": "error", "message": "Authentication required for non-public namespaces"}

TTL

Schema responses use TTL_OP_INFO = 3600 seconds (1 hour) as schemas change infrequently.

Response Format

Production v1 emits the successful data subset documented below. Fields and statuses for redirects, multi-record chunking, streaming, and protocol-managed encryption remain planned design vocabulary and are not shipped contracts.

TXT Record Response (v1)

v=rdb1;s=<status>;t=<type>;e=<encoding>;f=<format>;c=<chunks>;h=<hash>;ttl=<seconds>;sig=<signature>;seq=<sequence>;ts=<timestamp>;err=<error-code>;retry=<seconds>;d=<data>
FieldDescriptionValues
vProtocol versionrdb1
sStatus codeSee status codes
tResponse typedata, url, multi, stream, encrypted
eEncodingplain, b64, b32, hex, compressed, encrypted
fFormatjson, xml, protobuf, msgpack, text, binary
cChunk infocurrent/total (e.g., 1/3)
hSHA-256 hashFirst 16+ chars
ttlApplication-visible configured TTL hintSeconds; DNS resolver caching is controlled independently by the RR TTL
sigEd25519 signatureBase64 encoded
seqSequence numberFor ordering multi-part
tsTimestampUnix epoch
errError codeMachine-readable error (e.g., E001)
retryRetry afterSeconds until retry is appropriate
dData payloadEncoded data

Status Codes

CodeHTTP EquivDescription
ok200Success
partial206Partial content (chunked response)
redirect301See URL in data
notfound404Resource not found
auth401Authentication required
forbidden403Access denied
ratelimit429Too many requests
invalid400Malformed query
toolarge413Response exceeds limits
secviol400Security violation (signature invalid, replay detected)
error500Server error
unavail503Service unavailable

Error Codes

Machine-readable error codes for programmatic handling:

CodeStatusDescriptionRetryableRecovery Strategy
E001invalidMalformed query syntaxNoFix query format
E002invalidUnknown operationNoUse valid operation
E003invalidInvalid encoding prefixNoUse b64-, hex-, etc.
E004notfoundResource does not existNoCheck resource path
E005notfoundNamespace does not existNoRegister namespace first
E006authMissing authenticationNoInclude auth-<token>
E007authToken expiredNoRefresh token
E008authToken invalidNoCheck token format/signature
E009forbiddenInsufficient permissionsNoRequest access grant
E010ratelimitRate limit exceededYesWait for retry seconds
E011toolargeRendered value exceeds the DNS envelopeNoReduce the decoded payload below 2,586 bytes
E012errorInternal server errorYesRetry with backoff
E013unavailService temporarily unavailableYesRetry with backoff

Error Response Example:

v=rdb1;s=ratelimit;err=E010;retry=60;d=Rate limit exceeded

Private Namespace Error Privacy

The hosted-record gate returns DNS REFUSED for a missing, unknown, expired, revoked, or wrong-namespace query token and for an unknown private namespace. These cases are intentionally indistinguishable and do not depend on a customer-configurable privacy mode.

Response Examples

# Success with JSON
v=rdb1;s=ok;t=data;e=plain;f=json;h=a1b2c3d4e5f6g7h8;ttl=300;d={"temp":72,"unit":"F"}

# GeoIP response
v=rdb1;s=ok;t=data;e=plain;ip=8.8.8.8;cc=US;cn=United States;rg=California;ct=Mountain View;lat=37.386;lon=-122.084;tz=America/Los_Angeles;isp=Google LLC

DNS over HTTPS (DoH)

ResolveDB supports DNS over HTTPS per RFC 8484, providing encrypted DNS queries via HTTP/S.

Endpoints

EndpointMethodFormatDescription
/dns-queryGETWire or JSON?dns= β†’ RFC 8484 wire; ?name= β†’ JSON (shared with /resolve)
/dns-queryPOSTWireRFC 8484 with application/dns-message body
/resolveGETJSONGoogle-style JSON API for browser/debug use

Content Negotiation & Precedence (/dns-query GET)

/dns-query GET is param-authoritative and deterministic (Cloudflare-compatible):

  1. dns= present β†’ WIRE (RFC 8484, unchanged bytes/headers). If BOTH dns= and name= are present, WIRE wins β€” never an error.
  2. else name= present β†’ JSON resolve (the SAME path as /resolve).
  3. neither β†’ a fully-static FORMERR 400 (Status:1, Comment a constant string; no echo of any key/value/Accept/qname).

The Accept header is acceptability-only and NEVER routes: a missing or */* Accept (the browser default) with name= resolves to JSON 200 β€” this is the canonical way to use the JSON API on /dns-query. Because routing is param-driven, there is intentionally no Vary: Accept.

cd and edns_client_subnet are handled on the name= path identically to /resolve. cd is copied to the response flag; ECS is echoed for compatibility but is not used for routing. Validation failures (oversize/invalid name, bad type) return a generic FORMERR that does NOT echo the supplied name/type. Content-Type is always branch-derived: wire β†’ application/dns-message, JSON β†’ application/dns-json; nosniff on every branch including the static 400; JSON answers are always Cache-Control: no-store (authed answers are TTL-0). Both branches flow through the deny-by-default namespace gate β€” there is no JSON fast-path that skips authorization.

Wire Format (/dns-query)

Standard RFC 8484 DNS wire format over HTTPS.

GET Request:

# Generate a ResolveDB DNS query with a DNS library, then Base64url-encode the
# wire bytes without padding.
curl "https://doh.resolvedb.io/dns-query?dns=<base64url-wire-query>" \
  -H "Accept: application/dns-message"

POST Request:

curl -X POST "https://doh.resolvedb.io/dns-query" \
  -H "Content-Type: application/dns-message" \
  -H "Accept: application/dns-message" \
  --data-binary @query.bin

Response:

  • Content-Type: application/dns-message
  • Body: DNS wire format response
  • Cache-Control: max-age=<min-TTL> or no-store for errors

JSON API (/resolve)

Google-compatible JSON API for DNS queries. Easier to use from web applications and debugging tools.

Request:

GET /resolve?name=<domain>&type=<type>[&cd=<bool>][&do=<bool>][&edns_client_subnet=<subnet>]

Query Parameters:

ParameterRequiredDefaultDescription
nameYes-Query name (max 253 chars)
typeNoAQuery type (numeric or string: A, AAAA, MX, TXT, etc.)
cdNofalseCopied to the Checking Disabled flag; the authoritative service does not perform recursive validation
doNofalseAccepted for compatibility and currently ignored
edns_client_subnetNo-Echoed in JSON and not used for resolution
ctNo-Content-type hint (ignored, always returns JSON)
random_paddingNo-Accepted and ignored

Supported Query Types:

StringNumericDescription
A1IPv4 address
AAAA28IPv6 address
CNAME5Canonical name
MX15Mail exchange
NS2Nameserver
TXT16Text record
SOA6Start of authority
PTR12Pointer record
SRV33Service record
CAA257Certificate authority
HTTPS65HTTPS service binding
SVCB64Service binding
NAPTR35Naming authority pointer
DS43Delegation signer
DNSKEY48DNSSEC key
RRSIG46DNSSEC signature
NSEC47Next secure
ANY255Any record type

Response Format:

{
  "Status": 0,
  "TC": false,
  "RD": true,
  "RA": false,
  "AD": false,
  "CD": false,
  "Question": [
    {"name": "get.london.weather.public.v1.resolvedb.net", "type": 16}
  ],
  "Answer": [
    {"name": "get.london.weather.public.v1.resolvedb.net", "type": 16, "TTL": 300, "data": "\"v=rdb1;s=ok;t=data;...\""}
  ],
  "Authority": [],
  "Additional": [],
  "edns_client_subnet": "1.2.3.0/24",
  "Comment": "Optional comment"
}

Response Fields:

FieldTypeDescription
StatusnumberDNS RCODE (0=NOERROR, 2=SERVFAIL, 3=NXDOMAIN)
TCbooleanTruncated flag
RDbooleanRecursion Desired
RAbooleanRecursion Available (always false)
ADbooleanAuthenticated Data (DNSSEC)
CDbooleanChecking Disabled
QuestionarrayQuestion section
AnswerarrayAnswer records
AuthorityarrayAuthority records
AdditionalarrayAdditional records (excluding OPT)
edns_client_subnetstringEchoed ECS if provided
CommentstringOptional error/info message

Examples:

# ResolveDB TXT query
curl "https://doh.resolvedb.io/resolve?name=get.london.weather.public.v1.resolvedb.net&type=TXT"

# Explicit GeoIP query
curl "https://doh.resolvedb.io/resolve?name=geoip.ip-8-8-8-8.public.v1.resolvedb.net&type=TXT"

DoH Security

FeatureImplementation
Size limits4KB max query, 8KB max base64 parameter
Client IPThe socket peer must match DOH_TRUSTED_PROXIES; the right-most X-Forwarded-For hop appended by kamal-proxy identifies the previous peer; CF-Connecting-IP/True-Client-IP is honored only when that hop matches DOH_EDGE_PROXIES. Any incomplete or untrusted chain falls back toward the verified peer.
CORSAny origin on the public query API
Cache-ControlWire answers use DNS TTL; JSON is always no-store
Content-Typeapplication/dns-message (wire) or application/dns-json (JSON API, both /resolve and /dns-query?name=)

Implementation Status

FeatureStatus
RFC 8484 GETImplemented
RFC 8484 POSTImplemented
JSON API /resolveImplemented
Public CORSImplemented
Verified proxy-chain client IPImplemented
Size validationImplemented
Cache-Control headersImplemented

GeoIP Operation

The geoip operation returns geographic location data for a specified IP address. Following the Privacy by Design principle, the IP address MUST be provided as an explicit parameter.

Query Format

geoip.ip-<encoded-ip>.public.v1.resolvedb.net

IP Encoding:

  • IPv4: Replace dots with hyphens (e.g., 8.8.8.8 β†’ ip-8-8-8-8)

Response

v=rdb1;s=ok;t=data;e=plain;ip=8.8.8.8;cc=US;cn=United States;rg=California;ct=Mountain View;lat=37.386;lon=-122.084;tz=America/Los_Angeles;isp=Google LLC

Examples

# Lookup a specific IPv4 address
dig TXT geoip.ip-8-8-8-8.public.v1.resolvedb.net +short

# Lookup Cloudflare DNS
dig TXT geoip.ip-1-1-1-1.public.v1.resolvedb.net +short

Privacy Note

The server does NOT use the querier's source IP for GeoIP lookups. The client must explicitly provide the IP address they want to look up. This ensures:

  • Consistent results regardless of where the query originates
  • Correct behavior through DoH/DoT resolvers, VPNs, and proxies
  • Source IP is not substituted as the lookup target; direct DoH still sees the connecting IP
  • Cacheable responses (same query = same result)

WebSocket Session Security [PLANNED]

The watch operation and WebSocket endpoint are not implemented. The following section records design requirements only.

Session Token Format

Session tokens MUST be cryptographically secure and short-lived:

session_token = Base64URL(HMAC-SHA256(server_secret,
    tenant_id || resource_path || created_timestamp || client_ip_hash
))[0:32]  # 256-bit truncated to 32 chars
ComponentPurpose
tenant_idBinds session to authenticated user
resource_pathBinds to specific watched resource
created_timestampEnables expiration check
client_ip_hashOptional IP binding for added security

Connection Security

Handshake Requirements:

StepRequirement
1Client connects with Origin header matching allowed origins
2Server validates session token (MUST be < 5 minutes old)
3Server validates client IP matches token creation IP (optional)
4Server sends initial resource state
5Bidirectional communication established

Rate Limiting:

  • Maximum 10 WebSocket connections per tenant per minute
  • Maximum 100 concurrent connections per tenant

Session Timeouts:

TimeoutDurationAction
Idle30 minutesDisconnect with close code 1000
Maximum24 hoursForce reconnection with new token
Token validity5 minutesReject if token older

Reconnection Protocol

On disconnect, clients MUST:

  1. Obtain new session token via fresh watch DNS query
  2. Connect with new token (old tokens are single-use)
  3. Server sends full state, not just delta

Token Single-Use Enforcement:

Session tokens are consumed on first use. Reusing a token returns:

WebSocket close code: 4401
Reason: "Session token already used"

URL Security Concerns

Session tokens in WebSocket URLs are visible in:

  • Server access logs
  • Browser history
  • Referrer headers (if page navigates)

Mitigations:

  • Short token validity (5 minutes)
  • Single-use tokens
  • Consider passing token via WebSocket subprotocol header: Sec-WebSocket-Protocol: resolvedb-v1, token-<session_token>

Error Codes

Close CodeMeaning
4400Invalid session token
4401Session token already used
4403Access denied to resource
4429Rate limit exceeded

Pagination [PLANNED]

DNS list/search pagination is not implemented. Use the REST API to list hosted records. The following cursor design is non-normative.

For list and search operations that return multiple results, pagination is supported via cursor-based navigation.

Query Parameters

ParameterFormatDescription
limit-Nlimit-50Maximum results per page (1-1000, default 100)
offset-Noffset-200Skip N results (for simple pagination)
cursor-TOKENcursor-abc123Opaque cursor for next page

Response Fields

{
  "items": [...],
  "cursor": "eyJsYXN0X2lkIjoiMTIzIn0",
  "hasMore": true,
  "total": 523
}
FieldDescription
itemsArray of results for current page
cursorOpaque token for next page (Base64-encoded, URL-safe, HMAC-signed)
hasMoreBoolean indicating more results exist
totalTotal count (approximate for large sets, omit for privacy-sensitive namespaces)

Cursor Integrity (CRITICAL)

Cursors MUST be cryptographically signed to prevent manipulation attacks.

Cursor Format:

cursor = Base64URL(cursor_data) + "." + Base64URL(signature)

cursor_data = JSON({
    "last_id": "<last_item_id>",
    "tenant": "<tenant_id>",
    "query_hash": "<sha256_of_original_query_params>",
    "created": <unix_timestamp>
})

signature = HMAC-SHA256(server_secret, cursor_data)[0:16]  # 128-bit truncated

Validation Requirements:

Servers MUST:

  1. Verify HMAC signature before using cursor
  2. Reject cursors older than 1 hour (prevents stale enumeration)
  3. Verify tenant matches authenticated user (if applicable)
  4. Verify query_hash matches current query parameters (prevents cross-query cursor reuse)

Attack Prevention:

AttackMitigation
Cursor tamperingHMAC signature verification
Cross-user cursor theftTenant binding in cursor data
Cross-query cursor reuseQuery hash binding
Stale cursor enumeration1-hour expiration

Error Response:

Invalid cursors return:

v=rdb1;s=invalid;err=E017;d=Invalid or expired cursor
CodeStatusDescription
E017invalidCursor validation failed

Privacy Considerations

For privacy-sensitive namespaces:

  • total field SHOULD be omitted or return approximate value
  • Consider capping display at "100+" to prevent exact enumeration

Example

# First page
list.limit-50.resources.hooli.v1.resolvedb.net
-> {"items":[...],"cursor":"eyJsYXN0IjoiZm9vIn0","hasMore":true,"total":150}

# Next page (cursor must fit in 63-char DNS label)
list.cursor-eyJsYXN0IjoiZm9vIn0.resources.hooli.v1.resolvedb.net
-> {"items":[...],"cursor":"eyJsYXN0IjoiYmFyIn0","hasMore":true,"total":150}

# Last page
list.cursor-eyJsYXN0IjoiYmFyIn0.resources.hooli.v1.resolvedb.net
-> {"items":[...],"hasMore":false,"total":150}

DNS Label Constraints

Cursors must fit within DNS label limits:

  • Maximum 63 characters per label
  • URL-safe Base64 encoding (no +, /, or =)
  • For long cursors, use hash reference: cursor-h-<hash> where hash points to stored cursor state

Large Data (NULL Records) [PLANNED]

Production hosted records do not expose NULL-record storage, blob fallback, or multi-query chunk reassembly. The REST API accepts at most 2,586 decoded bytes per hosted record so the rendered UQRP value fits the 3,500-byte core limit. The following larger-data design is non-normative and MUST NOT be used by clients.

For data >4KB, use NULL record type (up to 64KB per record):

get.auth-rdbq<52>.bigdata.hooli.v1.resolvedb.net TYPE=NULL

Amplification Attack Mitigation (CRITICAL)

NULL records present significant DDoS amplification risk:

  • Minimum query size: ~32 bytes
  • Maximum response size: 65,536 bytes
  • Amplification factor: 2,048x

Mandatory Mitigations (per RFC 5358):

MitigationRequirementImplementation
Listener admission limitingREQUIRED10 NULL queries/second per source prefix and transport channel; staged in observe mode before enforcement
TCP FallbackREQUIREDResponses >4KB MUST use TC bit, require TCP
AuthenticationREQUIREDHosted NULL records require an auth-rdbq... query token; Rails API keys are not DNS credentials
Source ValidationRECOMMENDEDBCP 38/84 ingress filtering

Protocol Behavior:

# UDP query for large data:
Query:  get.auth-rdbq<52>.bigdata.hooli.v1.resolvedb.net TYPE=NULL (UDP)
Response: v=rdb1;s=toolarge;err=E015;d=Use TCP for responses >4KB;tc=1

# New error code:
E015 | toolarge | Response requires TCP | Yes | Retry over TCP

Size Limits by Transport:

TransportMax ResponseBehavior
UDP4,096 bytesTC bit set if exceeded
TCP65,536 bytesFull response allowed
DoH65,536 bytesFull response allowed

Rate Limits for NULL Records:

ScopeNULL queries/secBurstNotes
Source /24 IPv4 or /48 IPv6, per transport channel1010Same abuse bound for every tier; not a billing entitlement

Chunking Protocol

# 1. Get manifest (includes per-chunk hashes for integrity)
get.manifest.bigfile.hooli.v1.resolvedb.net
-> {"chunks":5,"size":320000,"hash":"abc123def456789012345678901234567890123456789012345678901234","chunk_hashes":["hash0","hash1","hash2","hash3","hash4"]}

# 2. Retrieve chunks (can be parallel, format: chunk-index-total-hash)
# Hash reference MUST be at least 16 hex chars (64 bits)
get.chunk-0-5-abc123def4567890.bigfile.hooli.v1.resolvedb.net  TYPE=NULL
get.chunk-1-5-abc123def4567890.bigfile.hooli.v1.resolvedb.net  TYPE=NULL
...

# 3. Verify each chunk hash, then verify full content hash after reassembly

Chunk Integrity Verification

Clients MUST:

  1. Verify each chunk's SHA-256 hash matches chunk_hashes[index] before storing
  2. Verify reassembled content SHA-256 matches manifest hash
  3. Reject chunks with mismatched hashes (do not retry automatically - may indicate MITM)
  4. Complete all chunks within 5 minutes or restart (prevents resource exhaustion)

Encryption Wire Format [PLANNED]

Production does not emit protocol-managed encrypted response envelopes. Applications may encrypt values before Base64-encoding them for the REST API, but key and nonce management remains entirely client-side. The following wire format is design material only.

For encrypted responses (t=encrypted), the following wire format is used.

AES-256-GCM Structure

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Encrypted Response                        β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  Nonce (12 bytes)  β”‚  Ciphertext (variable)  β”‚  Tag (16 bytes) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
ComponentSizeDescription
Nonce12 bytesUnique per encryption (random or counter-based)
CiphertextVariableEncrypted payload
Auth Tag16 bytesGCM authentication tag

Key Derivation (CRITICAL)

Keys are derived using HKDF-SHA256 with mandatory context binding:

shared_secret = X25519(client_private, server_ephemeral_public)
               OR X25519(server_private, client_public)

encryption_key = HKDF-SHA256(
    ikm  = shared_secret,
    salt = "resolvedb-v1-encryption",
    info = context_info,  # MANDATORY - see below
    len  = 32
)

Context Binding Requirements (MANDATORY):

The context_info field MUST include all of the following to prevent key reuse attacks:

ComponentFormatPurpose
Query FQDNUTF-8 bytesPrevents cross-query key reuse
Client ephemeral pubkey32 bytesBinds to specific client
Server ephemeral pubkey32 bytesBinds to specific response
Timestamp8 bytes (big-endian Unix epoch)Prevents replay
Nonce8 bytes (random)Additional entropy

Context Construction:

context_info = concat(
    length_prefix(query_fqdn),         # 2-byte length + UTF-8 FQDN
    client_ephemeral_pubkey,           # 32 bytes
    server_ephemeral_pubkey,           # 32 bytes
    timestamp_be64,                    # 8 bytes (Unix timestamp, big-endian)
    random_nonce                       # 8 bytes (cryptographically random)
)

Security Rationale:

Without complete context binding:

  • Same FQDN from different clients could derive same key
  • Responses could be replayed to different sessions
  • Keys could be precomputed for known FQDNs

Implementation Check:

# CORRECT: Full context binding
context = (
    len(fqdn).to_bytes(2, 'big') + fqdn.encode() +
    client_pubkey +     # 32 bytes
    server_pubkey +     # 32 bytes
    timestamp_bytes +   # 8 bytes
    random_nonce        # 8 bytes
)

# WRONG: Incomplete binding
context = fqdn.encode()  # Missing keys, timestamp, nonce

Ephemeral Key Format

The k field in encrypted responses contains the server's ephemeral X25519 public key:

v=rdb1;s=ok;t=encrypted;e=aes256gcm;k=<base64-ephemeral-pubkey>;d=<base64-encrypted-payload>
FieldFormatDescription
kBase64 (32 bytes decoded)Server ephemeral X25519 public key
dBase64Nonce + Ciphertext + Tag concatenated

Complete Example

Response:

v=rdb1;s=ok;t=encrypted;e=aes256gcm;k=MCowBQYDK2VuAyEAe8RB0...;d=dGVzdCBub25jZQAAAA...

Decoding d:

Base64 decode -> raw_bytes
nonce      = raw_bytes[0:12]      # 12 bytes
ciphertext = raw_bytes[12:-16]    # variable length
tag        = raw_bytes[-16:]      # 16 bytes

Decryption:

from cryptography.hazmat.primitives.ciphers.aead import AESGCM

# Derive shared secret from client private key and server ephemeral public
shared = x25519(client_private_key, server_ephemeral_public)
key = hkdf_sha256(shared, salt=b"resolvedb-v1-encryption", info=query_fqdn, length=32)

# Decrypt
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce, ciphertext + tag, associated_data=None)

Multi-TLD Root Server Redundancy [PLANNED]

Only resolvedb.net is delegated to and served by the ResolveDB authoritative fleet today. The following multi-TLD topology is a future design:

TLDPrimaryCross-Backup
.comns1/ns2.resolvedb.comns-backup.resolvedb.net
.netns1/ns2.resolvedb.netns-backup.resolvedb.org
.orgns1/ns2.resolvedb.orgns-backup.resolvedb.io
.ions1/ns2.resolvedb.ions-backup.resolvedb.com

Client Failover

ROOT_SERVERS = ['resolvedb.com', 'resolvedb.net', 'resolvedb.org', 'resolvedb.io']

def query_with_redundancy(resource):
    # Sort by health/latency
    for tld in sorted_by_health(ROOT_SERVERS):
        try:
            result = dns_query(f"{resource}.{tld}")
            mark_healthy(tld)
            return result
        except DNSError:
            mark_unhealthy(tld)
            continue
    raise AllTLDsFailedError()

Benefits

  • TLD-level failure protection
  • DDoS mitigation (attack one TLD, others continue)
  • Load distribution across infrastructures
  • Regulatory compliance (different jurisdictions)
  • Performance optimization (clients choose fastest)

Security Protocol (RDBSP)

Layer 1: DNSSEC Foundation

  • ECDSA P-256 KSK and Ed25519 ZSK for resolvedb.net
  • Persistent keys with in-process RRSIG refresh every 15 days
  • NSEC black lies for authenticated negative responses
  • Clients SHOULD verify AD flag

NSEC3 Parameters [PLANNED, NOT USED IN PRODUCTION]

Production uses NSEC black lies, not NSEC3. The parameters below are retained as non-normative design notes only.

ParameterValueRationale
Hash AlgorithmSHA-1 (1)Required by RFC 5155
Iterations0-10Per RFC 9276 guidance (low for online signing)
Salt Length0-8 bytesRandom salt, rotate with ZSK
Opt-OutDisabledAll names authenticated

NSEC3PARAM Record:

resolvedb.net. NSEC3PARAM 1 0 10 <random-salt-hex>

Salt Rotation:

  • Rotate salt with each ZSK rotation (30 days)
  • Use cryptographically random salt (minimum 64 bits)
  • Zero-length salt acceptable per RFC 9276

Iteration Count Guidance (RFC 9276):

  • Online signing: 0-10 iterations (performance)
  • Offline signing: Up to 100 iterations acceptable
  • Higher iterations provide minimal security benefit but significant CPU cost

Layer 2: Content Integrity [PLANNED RDBSP]

These hashes, request signatures, timestamps, and nonces are not required by the production rdbq namespace-token gate. This section is future RDBSP design.

  • SHA-256 hash verification (minimum 16 chars, full recommended)
  • Ed25519 signatures for authenticity
  • Unix timestamps for replay protection (5-second max tolerance)
  • Cryptographic nonces (MANDATORY for authenticated requests)

Replay Protection Requirements (CRITICAL)

Timestamp Tolerance:

ContextMax ToleranceRationale
Authenticated requests5 secondsLimits replay window
Unsigned public queries30 secondsAllows for clock skew
Encrypted responses5 secondsBound to ephemeral keys

Nonce Requirements:

For authenticated requests (auth-* prefix), clients MUST include a nonce:

get.auth-<jwt>.ts-<unix_timestamp>.nonce-<8-random-chars>.resource.namespace.v1.resolvedb.net
FieldFormatRequirements
ts-Unix timestampWithin 5 seconds of server time
nonce-8 alphanumeric charsCryptographically random, unique per request

Server-Side Tracking:

Servers MUST:

  1. Reject requests with ts more than 5 seconds from server time
  2. Track (nonce, ts) pairs for 10 seconds (2x tolerance window)
  3. Reject duplicate (nonce, ts) pairs with secviol status
  4. Use constant-time comparison for nonce matching

New Error Code:

CodeStatusDescriptionRetryableRecovery
E016secviolReplay attack detectedNoGenerate new nonce

Clock Synchronization:

Clients SHOULD:

  • Use NTP or similar for time synchronization
  • Include RTT estimate in tolerance calculations
  • Retry with fresh timestamp on E016 (but not same nonce)

Layer 3: Encryption Modes

Public (Integrity Only):

- Plaintext data
- SHA-256 hash
- Ed25519 signature
- DNSSEC transport

Symmetric (Shared Secret):

- AES-256-GCM encryption
- Pre-shared keys (out-of-band)
- Argon2id key derivation
- AEAD

Asymmetric (Public Key):

- X25519 key exchange
- ChaCha20-Poly1305 encryption
- Ephemeral keys (PFS)
- Public keys in TLSA records

Layer 4: Query Privacy

UDP, TCP, DoH, and DoT use the same query gate. The server does not reject a valid authenticated query solely because it arrived over plaintext DNS. Clients SHOULD use DoH or DoT whenever a qname contains an auth- label because the complete qname, including the bearer credential, is observable on UDP/TCP.

Plaintext DNS Exposure Warning:

Queries over plaintext DNS expose to all network observers:

  • Hosted namespace names
  • Resource names being accessed
  • Access timing patterns
  • Query frequency

Authentication [PLANNED JWT DESIGN]

Production authoritative DNS and DoH do not call the JWT verifier. They accept only opaque namespace query tokens in the auth-rdbq... form. Rails customer JWTs and API keys are REST-only. The JWT and hash-reference material below is a self-managed design and is not a shipped wire contract.

# JWT in auth- parameter (hyphen prefix, not colon)
get.auth-<jwt>.resource.namespace.v1.resolvedb.net

Algorithm Requirements (CRITICAL)

Allowed Algorithms:

AlgorithmUse CaseStatus
EdDSA (Ed25519)Primary signing algorithmREQUIRED
ES256ECDSA P-256 (legacy compatibility)ALLOWED
RS256RSA 2048+ (legacy compatibility)ALLOWED

Forbidden Algorithms:

AlgorithmReasonAction
noneNo signatureMUST reject with secviol
HS256, HS384, HS512Symmetric key confusion riskMUST reject
PS256, PS384, PS512Implementation complexitySHOULD reject

Algorithm Confusion Prevention:

Implementations MUST:

  1. Explicit allowlist: Only process tokens with algorithms from the allowed list above
  2. Pre-parse validation: Check alg header BEFORE any signature verification
  3. Reject before decode: If alg is forbidden, reject immediately without attempting verification
  4. Case-sensitive matching: "alg": "None" and "alg": "NONE" MUST also be rejected
  5. Key type binding: RSA keys MUST only verify RS*/PS* algorithms; EC keys MUST only verify ES* algorithms

Implementation Pattern:

# BEFORE any JWT library processing:
header = base64url_decode(token.split('.')[0])
if header.get('alg') in ['none', 'None', 'NONE', 'HS256', 'HS384', 'HS512']:
    return error('secviol', 'E008', 'Forbidden algorithm')

JWT Claims Specification

Required Claims:

ClaimTypeDescription
substringSubject (user ID or service ID)
issstringIssuer (resolvedb.io or tenant issuer)
audstringAudience (must include resolvedb.io)
expintegerExpiration time (Unix timestamp)
iatintegerIssued at (Unix timestamp)
nbfintegerNot before (Unix timestamp)
jtistringJWT ID (unique token identifier for revocation)
tenantstringNamespace/tenant identifier
scopesarrayPermission scopes (e.g., ["read", "write"])

Optional Claims:

ClaimTypeDescription
rate_limit_tierstringOverride tier: free, pro, enterprise
metadataobjectArbitrary key-value metadata
noncestringReplay protection nonce

Example JWT Payload:

{
  "sub": "user-12345",
  "iss": "resolvedb.io",
  "aud": "resolvedb.io",
  "exp": 1704153600,
  "iat": 1704067200,
  "nbf": 1704067200,
  "jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "tenant": "hooli",
  "scopes": ["read", "write", "list"],
  "rate_limit_tier": "pro"
}

Token Transport Constraints

DNS labels are limited to 63 characters. JWT tokens typically exceed this limit.

Solutions:

  1. Token Hash Reference (REQUIRED): Store token server-side, reference by cryptographic hash

    get.auth-h-<32-hex-chars>.resource.namespace.v1.resolvedb.net

    Security Requirements:

    • Token references MUST use HMAC-SHA256 with a server-side secret key
    • Reference MUST be at least 128 bits (32 hex characters) to prevent brute-force
    • Format: auth-h-<first-32-hex-chars-of-HMAC-SHA256(server_secret, token)>
    • Server MUST maintain token-to-reference mapping with TTL matching token's exp claim
    • References MUST be invalidated when corresponding token is revoked
    • Server SHOULD rate-limit auth-h- queries to prevent enumeration attacks
  2. Short-Lived Tokens: Use compact tokens with minimal claims (max 5 minutes validity)

  3. Multi-Label Split: Spread token across labels - NOT RECOMMENDED due to:

    • Increased attack surface (multiple labels to intercept)
    • Complex reassembly logic prone to implementation errors
    • No integrity protection across labels

Recommended Pattern: Use the HTTP API to exchange a full JWT for a cryptographically-signed token reference, then use that reference in DNS queries. The reference exchange endpoint MUST require TLS 1.3+.

DNS Compliance (RFC 1035/1123)

Absolute Limits

  • Max FQDN: 253 characters (excluding trailing dot, per RFC 1035 Section 2.3.4)
  • Max label: 63 characters
  • Max labels: 127 levels
  • Valid chars: a-z, A-Z, 0-9, - (hyphen, not at label edges)

Important: Colons (:) are NOT valid DNS characters. UQRP uses hyphens (-) for encoding prefixes (e.g., b64- not b64:).

Case Normalization (CRITICAL)

Per RFC 1035 Section 2.3.3, DNS names are case-insensitive. Implementations MUST normalize consistently to prevent security issues.

Normalization Requirements:

ComponentNormalization PointRule
Query FQDNImmediately at parseLowercase before ANY processing
NamespaceImmediately at extractionLowercase before authorization check
Cache keyAfter normalizationUse normalized form only
Auth comparisonAll comparisonsCase-insensitive or pre-normalized

Security Rationale:

Without consistent normalization, attackers can exploit case differences:

# Attack: Cache poisoning via case confusion
1. Victim caches response for: get.data.VICTIM.v1.resolvedb.net
2. Cache key uses: get.data.victim.v1.resolvedb.net (normalized)
3. Attacker queries with different case: get.data.Victim.v1.resolvedb.net
4. If parser extracts "Victim" but cache uses "victim", cross-user data leak

# Defense: Normalize BEFORE any extraction
namespace = extracted_namespace.to_lowercase()  # FIRST

Implementation Pattern:

// CORRECT: Normalize immediately at parse
fn parse_query(qname: &str) -> Result<ParsedQuery> {
    let normalized = qname.to_lowercase(); // FIRST OPERATION
    let parts: Vec<&str> = normalized.split('.').collect();
    // All subsequent operations use normalized form
}

// WRONG: Normalize only at cache time
fn get_cache_key(qname: &str) -> String {
    qname.to_lowercase() // TOO LATE - parser may have used original case
}

Length Budget

Base domain:     resolvedb.net           (12 chars)
Tenant:          hooli                   (4-10 chars)
Version:         v1                      (2 chars)
Separators:                              (3-10 chars)
Safety margin:                           (10 chars)
─────────────────────────────────────────────────────
Reserved:                                (~35 chars)
Available for data:                      (~218 chars)

Fallback Strategies

  1. Hash Reference: Store full data, query by hash
  2. Multi-Query: Split across queries
  3. Compression: Dictionary for common patterns
  4. Indirect: Short reference to full data

Listener Admission Limits

One process-wide source-prefix limiter is attached to authoritative UDP/TCP, DoT (which dnsdist forwards as TCP), RFC 8484 DoH, and JSON DoH. It is a volumetric listener abuse bound, not a billing allowance, per-tenant hard cap, or traditional response-signature RRL.

RRL_MODE controls the runtime posture:

ModeBehavior
offNo bucket allocation or decisions; code default
observeExercise the production buckets and emit bounded telemetry without changing responses
enforceApply the transport-specific actions below

The committed production deployment stages observe; changing to enforce requires the reviewed rollout in docs/runbooks/dns-rrl-launch.md.

Source Identity And Keys

Native DNS accepts an ECS source identity only when the backend socket peer is in RRL_DNSDIST_PROXIES and ECS has a full /32 IPv4 or /128 IPv6 source prefix. dnsdist is configured with setECSOverride(true) so an inbound ECS option cannot choose another source identity. Otherwise the socket peer is used.

DoH verifies the local proxy and Cloudflare edge chain as described under DoH Security. Untrusted or incomplete forwarding data cannot select the claimed Cloudflare client-IP value.

The key is:

(source /24 IPv4 or /48 IPv6, transport channel, risk bucket)

UDP and reliable transports have separate channels so a slipped UDP request can retry over TCP. TCP, DoT, and DoH share the reliable budget. Every decoded native DNS query and every DoH GET/POST query request consumes the standard bucket; NULL queries also consume a stricter additional bucket after qtype parse. DoH standard admission runs before HTTP query deserialization or body buffering. CORS OPTIONS is not a DNS query and remains an edge HTTP control concern. Native admission runs in Hickory's decoded-request handler, so malformed datagrams rejected before dispatch are outside this in-core control. Query names, namespaces, tokens, and client addresses never appear in metric labels.

Defaults And Actions

BucketSustained QPSBurst
UDP standard1,000100
Reliable standard1,000100
NULL, either channel1010

In enforce mode, over-limit UDP requests are silently dropped except for the configured slip percentage (default 2%), which receives an empty TC=1 response. TCP/DoT receives DNS REFUSED. DoH receives HTTP 429 with Retry-After: 1 and Cache-Control: no-store.

Limiter state is bounded by RRL_MAX_ENTRIES (default 100,000), divided evenly among UDP-standard, reliable-standard, UDP-NULL, and reliable-NULL partitions so spoofable UDP churn cannot consume reliable retry capacity. Stale entries expire after RRL_ENTRY_TTL_SECS (default 300). A new source rejected by its partition is treated as limited in enforce mode or recorded as would-limit in observe mode, and increments a separate bounded metric.

Per-tenant limits, tier-specific limits, adaptive DDoS controls, and response-signature/qname buckets are not implemented by this control.

Pluggable Provider Protocol (PPP)

Services can be implemented via MCPs or custom backends:

class ResolveDBProvider:
    name: str
    version: str
    capabilities: ProviderCapabilities

    def can_handle(self, query: DNSQuery) -> bool
    def execute(self, query: DNSQuery) -> DNSResponse
    def health_check(self) -> HealthStatus

Service Discovery:

_services.registry.resolvedb.net    TXT  "weather.v1,stock.v1,news.v1"
_meta.weather.v1.registry           TXT  "provider=OpenWeather;sla=99.9"
_health.weather.v1.registry         TXT  "status=healthy;latency=15ms"

Location-Based Queries

Following the Privacy by Design principle, location-based queries REQUIRE explicit location parameters. The server does NOT infer location from the client's IP address.

Location Parameter Formats

# Named location
get.newyork.weather.public.v1.resolvedb.net

# Coordinates: d is the decimal point and _ separates latitude/longitude
get.40d7128_-74d0060.weather.public.v1.resolvedb.net

# Explicit IP and what3words location forms
get.ip-8-8-8-8.weather.public.v1.resolvedb.net
get.w3w-filled-count-soap.weather.public.v1.resolvedb.net

# Coordinates via Base64-encoded JSON
# {"lat":40.7128,"lon":-74.0060} -> eyJsYXQiOjQwLjcxMjgsImxvbiI6LTc0LjAwNjB9
get.b64-eyJsYXQiOjQwLjcxMjgsImxvbiI6LTc0LjAwNjB9.weather.public.v1.resolvedb.net

Why Explicit Location?

Implicit (WRONG)Explicit (CORRECT)
Server infers from source IPClient provides location
Breaks through VPNs/proxiesWorks everywhere
Different target from different networksSame explicit target; time-varying provider data may still change
Privacy leakPrivacy preserved
Cache fragmentation (ECS scopes)Fully cacheable

Note: Colons (:) are not valid in DNS labels per RFC 1035. All parameters requiring special characters must use Base64 encoding or DNS-safe character substitution.

EDNS Client Subnet (ECS) Handling [PLANNED, NOT USED FOR ROUTING]

Production resolution does not vary by ECS and does not create ECS-scoped cache keys. The JSON compatibility parameter is echoed only. The requirements below are non-normative future design.

Privacy Implications:

ECS exposes client subnet information to authoritative servers. This creates privacy concerns:

  • Client location disclosed without explicit consent
  • Cached responses may leak location to subsequent queries
  • Third-party observers can correlate IP ranges to locations

Server Requirements:

RequirementImplementationRFC Reference
Scope Prefix HandlingREQUIREDRFC 7871 Section 7.3
Privacy Mode SupportREQUIREDRFC 7871 Section 12.3
Opt-Out MechanismREQUIREDClient can omit ECS

Scope Prefix Behavior:

Servers MUST include SCOPE PREFIX-LENGTH in ECS responses to indicate caching granularity:

# Query includes ECS with /24 prefix
# Server responds with /16 scope (less specific = broader caching)
Client: ECS 192.0.2.0/24
Server: ECS 192.0.0.0/16 SCOPE 16

# Cached response valid for all 192.0.x.x clients

Privacy Mode (ECS=0):

Clients MAY send ECS with SOURCE PREFIX-LENGTH=0 to indicate privacy preference:

  • Server MUST NOT use client subnet for response
  • Server MUST respond with SCOPE PREFIX-LENGTH=0
  • Response is not segmented by ECS; each recursive cache instance still has its own entry
# Privacy mode query
Client: ECS 0.0.0.0/0 (SOURCE=0)
Server: ECS 0.0.0.0/0 SCOPE=0

GeoIP Privacy Considerations: Production GeoIP queries contain an explicit target IP in the qname, do not infer the requester address, and use a 300-second TTL. They may be served through shared recursive resolvers. Use DoH or DoT when the explicit target itself is sensitive.

Cache Scope Pollution Prevention:

Implementations MUST separate cache entries by ECS scope:

Cache Key = (QNAME, QTYPE, QCLASS, ECS_SCOPE_PREFIX)

# Different cache entries:
get.london.weather.public.v1.resolvedb.net:TXT:IN:192.0.0.0/16
get.london.weather.public.v1.resolvedb.net:TXT:IN:198.51.0.0/16
get.london.weather.public.v1.resolvedb.net:TXT:IN:GLOBAL  # ECS=0 response

TTL Cache Delegation

Public ResolveDB answers can use standard DNS caches to reduce repeated authoritative lookups. Authenticated private answers use RR TTL 0 and do not receive this benefit.

RFC References: TTL semantics per RFC 1035 Section 3.2.1, negative caching per RFC 2308, stale serving per RFC 8767.

The Caching Multiplier

When ResolveDB returns a DNS RR with TTL 3600, each recursive cache that receives the query may retain its own copy. Corporate, ISP, and public resolvers are alternative or explicitly configured forwarding paths, not one universal serial hierarchy:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    TYPICAL DNS QUERY PATH                       β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚                                                                 β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                    β”‚
β”‚  β”‚ Client / OS  │────▢│ Configured         β”‚                    β”‚
β”‚  β”‚ stub cache   β”‚     β”‚ recursive/forwarderβ”‚                    β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                    β”‚
β”‚                                 β”‚ cache miss                     β”‚
β”‚                                 β–Ό                                β”‚
β”‚                       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                    β”‚
β”‚                       β”‚ ResolveDB          β”‚                    β”‚
β”‚                       β”‚ authoritative      β”‚                    β”‚
β”‚                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                    β”‚
β”‚                                                                 β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Example: If 10,000 requests sharing one corporate resolver cache entry query get.london.weather.public.v1.resolvedb.net while that entry remains hot within its 300-second DNS TTL:

  • Traditional API: up to 10,000 requests in that window
  • ResolveDB: approximately 1 authoritative request for that cache instance and key in that TTL window

For an ideal fixed-TTL cache instance receiving one key at request rate lambda with TTL T, the authoritative miss rate is approximately lambda / (1 + lambda*T). Aggregate authoritative rate sums that expression across cache instances and keys. Cold entries, eviction, prefetch, resolver floors, and stale-serving policies change the observed rate; total demand still matters when entries are not continuously hot.

TTL Classes

The implementation defines these conceptual policy constants. The shipped operation table below, not the example class names, is authoritative for current answers:

ClassTTLUse Case
immutable604800 (7 days)Conceptual settled/immutable policy
stable86400 (24 hours)Conceptual long-lived policy
standard3600 (1 hour)General default policy
dynamic300 (5 min)Frequently changing public data policy
volatile30-60 secRapidly changing public data policy
nocache0Authenticated private answers

Operation TTL Defaults

Shipped queryDefault TTLNotes
Public get / info3600Successful private get answers override this to 0
geoip / weather300Explicit target only
forecast1800Provider-backed
stock / forex60 while active, 3600 while closedProvider and market-state dependent
crypto / BTC60Continuously changing
units86400Deterministic conversion
sun / moon3600Time-dependent
temporal dataset identity3600 or 604800Long TTL only for a settled past window

DNS put, delete, list, search, health, and watch are not production UQRP operations. Mutations use the REST API.

Special Cases:

CaseTTL Behavior
Authenticated (auth-rdbq prefix)TTL 0 - private answers are excluded from shared caches
Unknown dataNODATA; negative caching derives from the SOA fields
ratelimit (429)TTL=1 - signal immediate retry
error (500)TTL=0 - don't cache server failures
unavail (503)TTL=0 - transient, retry immediately
Chunked data (chunk- prefix)Planned; no production chunk-reassembly protocol

Authenticated Query Cache Exclusion (CRITICAL)

Authenticated queries MUST NOT be cached to prevent cross-user data leakage.

Detection Requirements:

Implementations MUST detect authenticated queries through BOTH methods:

Detection MethodCoverageFallback
Full query parsingPrimary - extracts auth_token from parsed queryRequired
String matchingSecondary - checks for .auth- in QNAMEBackup only

Fail-Secure Behavior:

fn is_authenticated_query(query: &DNSQuery) -> bool {
    // PRIMARY: Parse the query structure
    match parse_resolvedb_query(query) {
        Ok(parsed) => parsed.auth_token.is_some(),
        Err(_) => true,  // FAIL SECURE: If parsing fails, assume authenticated
    }
}

Security Rationale:

Without strict auth detection:

  1. Encoded auth tokens (b64-<token-with-auth-inside>) bypass string matching
  2. Malformed queries may cache and serve to unauthorized users
  3. Cross-user cache poisoning becomes possible

Negative Caching (RFC 2308)

The production authoritative zone intentionally returns NODATA rather than NXDOMAIN for unknown UQRP names:

ResponseRCODEMeaningTTL Source
NODATA0 (empty answer)Name or requested type has no dataSOA negative-cache fields

Negative responses use DNSSEC-signed NSEC black lies.

TTL=0 Behavior Notes

TTL 0 directs compliant caches not to retain an answer. Implementations may apply brief floors or stale-serving policy; verify the behavior of the selected resolver rather than relying on product-specific values in this specification.

Private hosted-record answers use TTL 0. DNS writes and DNS write confirmations are not implemented; use the REST API for mutations.

Resolver TTL Capping

Resolver implementations may apply TTL floors, caps, prefetching, or stale serving. These behaviors vary by version and configuration; verify the selected resolver when cache lifetime is operationally important.

GeoIP/ECS Considerations

GeoIP lookup targets are explicit qname parameters. Production does not vary answers by ECS.

Implementation Status

FeatureStatusNotes
TTL in response formatImplementedttl=<seconds> is TXT metadata; the DNS RR carries the effective resolver TTL
SOA MINIMUM for negative cacheImplemented3600s default
Cache respects response TTLImplementedExtracts from first answer, clamps to ttl_min/ttl_max
Per-operation TTL defaultsImplementedttl_for_operation() and determine_answer_ttl() in protocol/constants.rs
TTL class constantsImplementedTTL_IMMUTABLE (7d), TTL_STABLE (24h), TTL_STANDARD (1h), TTL_DYNAMIC (5m), TTL_VOLATILE_MIN/MAX (30-60s)
Error cachingImplementedFailures use DNS RCODE/NODATA behavior; transient failures do not emit a cacheable positive answer
Auth query cache exclusionImplementedSuccessful private answers use TTL 0 and authenticated qnames are excluded from caches
Rate-limit cachingImplementedDoH 429 is no-store; native UDP slip and TCP/DoT REFUSED do not emit a positive UQRP answer

Protocol Evolution

Version Negotiation

Negotiation Process:

  1. Client queries with preferred version: get.london.weather.public.v2.resolvedb.net
  2. If server doesn't support v2, respond with redirect: v=rdb1;s=redirect;supported=v1;d=get.london.weather.public.v1.resolvedb.net
  3. Client retries with supported version

Anti-Downgrade Protection (CRITICAL):

Attackers may force clients to use older, vulnerable protocol versions:

ProtectionImplementation
Maximum downgradeClients MUST NOT downgrade more than one major version
Minimum versionServers advertise min_version in health endpoint
Version pinningClients MAY pin to specific versions for security-critical operations

Server Health Includes Version Info:

_health.system.resolvedb.net TXT "v=rdb1;status=healthy;versions=v1,v2;min_version=v1;recommended=v2"

Client Behavior:

def negotiate_version(preferred: str, server_supported: list) -> str:
    # Check minimum version requirement
    if server_min_version > client_minimum_acceptable:
        raise VersionError("Server requires newer client")

    # Only downgrade one major version
    pref_major = int(preferred[1:])
    for v in sorted(server_supported, reverse=True):
        v_major = int(v[1:])
        if v_major >= pref_major - 1:  # Max 1 version downgrade
            return v

    raise VersionError("No acceptable version available")

Backward Compatibility Rules

Change TypeAllowedRequires
Adding optional fieldsYesMinor version bump
Adding new status codesYesClients treat unknown as error
Adding new operationsYesClients return E002 for unknown
Adding new encoding prefixesYesClients reject unknown prefixes
Removing required fieldsNONew major version
Changing field semanticsNONew major version
Changing status code meaningsNONew major version
Changing encoding prefix formatNONew major version

Deprecation

info.old-service.deprecation.public.v1.resolvedb.net TXT "deprecated=true;sunset=2025-12-31;migrate=new-service.v2"

Deprecation Timeline:

  • Deprecation announcement: 6 months before sunset
  • Warning responses: 3 months before sunset (include deprecation=true in responses)
  • Sunset: Return redirect to new version

Domain Portfolio

DomainUsageStatus
resolvedb.netAuthoritative UQRP DNS zoneLive
resolvedb.ioCustomer web/docs and DoH (doh.resolvedb.io)Live
resolvedb.comRails API (api.resolvedb.com)Live
resolvedb.appApp endpointsReserved
resolvedb.devDeveloper portalReserved
resolvedb.orgDocumentationReserved
resolvedb.cloudCDN endpointsReserved
resolvedb.techTechnical demosReserved
resolvedb.caCanadian presenceReserved

Revision History

This specification is under active development. A public revision history will be published with the standalone protocol specification.