Overview
ResolveDB encodes public service data and hosted-record reads in DNS queries and responses. Public answers can use shared DNS caching; authenticated private positive and negative answers use TTL 0, including negative SOA/proof lifetime.
Implementation Status
This specification describes both implemented features and planned capabilities.
| Category | Status | Notes |
|---|---|---|
| DNS message parsing | Implemented | RFC 1035 compliant |
| TXT response format | Implemented | Ordinary public/hosted/schema answers carry v=rdb1;s=ok;t=data; dataset formats remain separate |
| Hosted payload round trip | Implemented | Rails write/sync → native DNS/DoH → Go/JS → MCP; terminal d=, standard padded Base64, byte-exact binary |
| Case-stable query parameters | Implemented | Lowercase unpadded RFC4648 b32- and hex-; case-insensitive decoding after authorization; retired query Base64 forms rejected |
| Client DNS outcome distinctions | Implemented | Go UDP/TCP/DoH, JS DoH, and MCP preserve absence, malformed query, denial, and failure; real shared schema/control conformance |
| Status/error envelope vocabulary | Partial | Successful data envelopes are shipped; request failures primarily use DNS RCODEs |
| Error codes E001-E013 | Partial | Defined vocabulary; not every production path emits an envelope code |
| Error code E014 | Reserved | Not emitted; UDP/TCP/DoH/DoT use the same query gate |
| Error codes E015-E022 | Planned | Security errors |
| JWT authentication (EdDSA) | Library only / planned | Utility exists, but authoritative DNS and DoH request paths do not call it |
Namespace query tokens (auth-rdbq…) | Implemented | Synced opaque tokens; private namespaces answer REFUSED without one |
| DNSSEC signing | Implemented | Shared hosted/schema/zone, financial/computed/location, manifest/keys and identity answering supplies requested native/DoH signatures and NSEC proofs |
| TTL behavior | Implemented | Hosted private positives/negatives have TTL 0, including SOA/proofs; public hosted TTLs are expiry-capped; current identity values and temporal negatives are transition-capped |
| DoH RFC 8484 (wire format) | Implemented | GET/POST /dns-query |
| DoH JSON API | Implemented | GET /resolve (Google-compatible) |
| DoT RFC 7858 | Implemented | Port 853 via dnsdist |
| Schema endpoint (info operation) | Implemented | JSON Schema via DNS + HTTP /schema |
Public services (units, sun, moon) | Implemented | Shared answering and flat ordinary envelopes; sun TTL is bounded by the next UTC day; moon without a date changes daily |
Location services (weather, forecast, geoip) | Implemented | Shared native/DoH answering, explicit locations, distinct weather/forecast fields and TTLs; requested signing fails closed |
btc chain-stats resource | Reserved (gated off) | BTC_SERVICE_ENABLED default false; mock data (src=mock) |
Financial services (stock, forex, crypto) | Implemented | Shared evaluation/assembly, market-aware TTLs, provider fallback, reference provenance, final RCODEs and native/DoH/client conformance |
| Namespace validation | Implemented | Rails enforces DNS-label syntax, length, uniqueness, and reserved names |
| Resource versions | Implemented | Canonical positive decimal labels, exact hosted/schema identities, explicit public v1 support; Rails, Go/JS, MCP and statistics agree |
| Evaluation analytics | Implemented | All native/DoH product families emit once before assembly; closed evaluated classes, verified private attribution, Redis-to-Rails conformance |
| Pagination (cursor-based) | Planned | HMAC-signed cursors |
| Special tokens (BDT, CTP) | Planned | Privacy tokens |
| NULL record mitigations | Planned | Amplification limits |
| EDNS Client Subnet | Compatibility only | JSON parameter is echoed, not used for routing |
Current implementation details live in this monorepo's resolvedb-core/ directory.
Shared UQRP Answering [IMPLEMENTED]
The contract accepted on 2026-09-14 is implemented across native UDP/TCP DNS,
DoT, wire DoH GET/POST and both JSON entry forms. Answerer::answer is the single
decoded-request-to-complete-DNS-response interface for public, hosted,
dataset/temporal and schema queries, plus ordinary authoritative zone controls.
Private evaluation and assembly own product decisions, accounting, final RCODEs,
lifetimes and requested integrity material. Transport adapters retain admission,
framing, HTTP presentation and delivery; there is no legacy catalog fallback.
Repository conformance includes actual transport and Go/JS/MCP consumers, Rails write/publish/sync, and Redis-to-Rails analytics/usage. See the reproducible gates and operator-controlled coordinated release procedure. This status does not assert production rollout or package publication. Unrelated planned capabilities elsewhere in this draft remain planned.
Completed slice (#33): hosted payload consumption now preserves terminal
d= data, standard padded TXT Base64, and optional metadata through both SDKs
and MCP. Completed slices (#36–37): schemas and zone controls use shared
answering; the clients preserve actual DNS outcomes as documented below.
Completed slice (#40): units, moon, and gated BTC use shared evaluation and
assembly, including ordinary flat fields, final RCODEs, resource TTLs, requested
DNSSEC, and no authenticated usage. Native/DoH and SDK/MCP conformance is covered
by just client-conformance.
Completed slice (#41): hosted private/public-read/demo evaluation and assembly
are shared, including authorization precedence, final RCODEs, TXT-only serving,
coherent expiry caps, private negative lifetime, requested DNSSEC, and evaluation
usage/statistics.
Completed slice (#39): stock, forex, and crypto share evaluation/assembly,
including market-aware TTLs, provider fallback, explicit-MIC fail-closed rules,
ordinary flat fields, final DNS outcomes, required DNSSEC and native/DoH query
statistics. just client-conformance covers generic and typed financial SDK/MCP
reads. Repository completion does not imply deployment.
Completed slice (#38): weather, forecast, sun, and GeoIP share explicit-location
evaluation and ordinary assembly. Forecast keeps provider daily data (including
d0) and its 1800-second policy; sun is bounded by UTC midnight. Native/DoH,
dnsdist DoT, SDK and MCP conformance exercises deterministic HTTP providers.
Location statistics use the actual family and public calls emit no authenticated
usage. Canonical encoded queries are implemented by #45 below.
Completed slice (#42): dataset manifests and attestation-key discovery use
shared native/DoH evaluation and assembly. Exact releases and the monotonic
latest alias retain rdb-attest.v1; keys use rdb-attest-keys.v1, independently
of DNSKEY. Real Rails publish/attest fixtures pass through sync, native/HTTP/DoT,
Go/JS and MCP, retaining opaque signatures and synthetic provenance. Dataset
outcomes have an explicit datasets statistic class without reader attribution
or private usage. Identity/as-of answering is also implemented (#43). The
complete contract remains subject to operator deployment and package publication.
Completed slice (#45): Base32/hex labels decode without changing byte case;
each public operation interprets those bytes after policy checks, including
coordinate JSON. Hosted reads retain ordered encoded-label identity. Rails rejects
malformed/retired parameter labels; Go/JS emit canonical b32- and MCP protects
both current and retired prefixes. Real native/DoH and consumer tests vary DNS
case/root dots, edge-hyphen/underscore parameters, and negative coordinates;
the Rails publisher gate covers encoded hosted keys too. HTTP decoders preserve
DNS label bytes without applying hostname/IDNA restrictions to parameters.
Wire DoH retains arbitrary DNS label octets through shared structural parsing;
invalid product-label octets are rejected after identifiable-query authorization,
just as on native DNS. Malformed DNS framing still fails at the transport boundary.
Structural resource labels retain LDH syntax (alphanumeric ends, no underscore)
across Rails writes, schema registration, shared evaluation, and SDK builders.
Completed slice (#46): every shared product evaluation emits one query-stat
attempt, including malformed product queries with no version. Native UDP/TCP and
all four DoH forms pass through real Redis publication and Rails ingestion/read
conformance (just analytics-conformance). Private hosted units is records;
dataset/schema/public lookups remain unattributed. Verified hosted attribution
survives miss/expiry. Usage remains hit-only, once before response assembly.
Production cutover verification remains operator-controlled.
Scope and representation
Completed slice (#44): resource versions are canonical v[1-9][0-9]*, with
DNS case equivalence (V1 → v1), at most 63 bytes per label and 253 bytes per
name excluding the root dot. Hosted records and registered schemas select exact
versions, including versions longer than 16 characters. Public lookup families
explicitly support v1; other canonical versions are REFUSED, malformed
versions are FORMERR after authorization/policy. Denied identifiable private
queries remain uniform REFUSED. Record writes reserve room for get and the
opaque auth label and reject unqueryable keys; existing stored identities are
never renamed. Dataset releases and envelope formats remain separate.
Statistics carry canonical versions or omit the field when the query has no
valid version (error/refused only); the Rails row and query-log API then expose
null. Revised query_stats.v2 has no v0 sentinel. The staged dual-contract
consumer preserves baseline v1 sentinels as unknown versions on any outcome;
apply nullable-version/provenance migrations and enable compatibility before the
fleet update. See the Query-Stats contract and rollout procedure below.
The coordinated analytics consumer/producer verification is described in
docs/runbooks/query-stats-launch.md; canonical encodings are implemented by #45.
- The target covers public lookups, hosted-record reads, and dataset/temporal queries, including their schema-query handling.
- Ordinary results use documented
rdb1formats. Dataset attestation and identity formats remain distinct; their trust meaning is not folded into an ordinary data envelope. - Given the same query and eligible data, every transport exposes the same product value and effective DNS TTL. Transport framing and JSON presentation remain representation concerns.
- Product answers use TXT. The native hosted-record NULL path has been removed. Ordinary zone queries and DNSSEC support records, including DNSKEY, SOA, NS, RRSIG, and denial proofs, are outside this TXT-only restriction.
- DNS case and a terminal root dot are normalized consistently. Supported operations and resource versions are checked consistently across transports.
Version vocabulary
- The qname version is a resource version, scoped to resource and namespace.
Public lookups expose explicitly supported versions, currently
v1; hosted records may use customer-defined resource versions. Record-write validation, query validation, and SDK builders must agree on admissible versions. rdb1and the dataset envelope identifiers describe envelope formats, separately from the selected resource version.- A dataset release version, such as
1.2.0, selects dataset content and remains distinct from both the query's resource version and envelope format. - Resource versions use canonical
v[1-9][0-9]*spelling:v1,v2, and so on. Zero, leading-zero, and nonnumeric forms are rejected rather than aliased. DNS case normalization makesV1equivalent tov1. The rule applies to record writes, query parsing, schema registration, SDK builders, and statistics validation; normal DNS label and full-name length limits still apply.
Query encodings and ordinary payloads
- The standard compact encoded-query form is unpadded RFC 4648 Base32 using the
b32-prefix, canonical lowercase spelling, and case-insensitive decoding.hex-remains supported. Decoded bytes retain their exact value and case; operation-specific interpretation happens after authorization. b64-,base64-, and Base64-derived custom query-label encodings such asrdb-are retired and rejected, not reinterpreted as plain parameters. DNS treats letter-case variants as the same name, whereas Base64 can assign them different bytes. Direct coordinate, IP, and what3words forms remain supported.- Ordinary successful payloads begin with
v=rdb1;s=ok;t=dataand retain their existing flat resource fields. This does not require every result to become ad=payload or acquire universale,f, orttlmetadata. - When present,
d=is the final field and consumes the remainder of the payload; semicolons in that remainder are data, not additional metadata. Join TXT character strings and decode DNS presentation escaping before interpreting this envelope grammar. - Ordinary TXT
e=b64means standard padded Base64. TXT payload data is case-sensitive. This is separate from both case-stable query-label encoding and RFC 8484's outerdns=parameter, which remains unpadded Base64url.
Result and failure semantics
| Product outcome | Required DNS result |
|---|---|
| Eligible value | NOERROR with a TXT answer |
| Valid query with no eligible value, including an expired hosted record | NOERROR with no answer |
| Malformed product query | FORMERR |
| Authorization or product-policy denial | REFUSED |
| Lookup failure or corrupt stored state | SERVFAIL |
These distinctions must survive final response construction on every transport. Authorization denials remain uniform. Corrupt temporal indexes must not trigger legacy-fact fallback, and an explicit historical query requires evidence that a fact was valid at the requested time.
Implemented client mapping (#37): Go uses errors.Is with ErrNotFound
for empty NOERROR, ErrInvalidQuery for FORMERR, ErrRefused for REFUSED,
and ErrServer for SERVFAIL. JavaScript throws NotFoundError,
InvalidQueryError, RefusedError, and ServerError, respectively. Eligible
TXT values still return the existing decoded response object. Other RCODEs
(including NXDOMAIN and EDNS extended codes) remain numeric
DNSResponseError / DnsResponseError failures, rather than invented denial
or UQRP absence. Existing non-ok rdb1 envelope error interfaces are retained.
MCP tools retain a redacted text message and expose
structuredContent.error.{code,message} with these distinct SDK codes.
Outer HTTP, media-type, malformed-wire, redirect, and network failures remain
separate from product outcomes. DoH accepts only application/dns-message
success responses, requires HTTPS, rejects redirects, and never downgrades.
Clients consume one product TXT RR (joining its character strings); ordinary
zone records and DNSSEC support material are not extra product values or proof
of client-side verification. An echoed AD flag is not local verification.
Request processing first validates enough structure to identify the query, then
applies namespace/product policy and authorization, validates operation-specific
parameters, and finally applies answer-type rules and performs the lookup. An
identifiable private query with an invalid token returns uniform REFUSED even
if its operation parameters are invalid; a structurally unrecognizable product
query returns FORMERR.
Consistent observations
A record's value and expiry must be observed together. A temporal item's facts and validity metadata must likewise form a coherent observation. Each signed response uses one zone/key generation. An in-flight query may use a coherent earlier state; unrelated records do not require a global snapshot. Partial publication of an update must not masquerade as missing or corrupt data.
Lifetime and integrity
- Cacheable results are capped by known record expiry and relevant validity transitions. Authenticated positive and negative results have zero DNS cache lifetime. Settled historical facts retain their long-cache policy.
- Record expiry, payload TTL hints, effective DNS TTL, and HTTP cache policy remain separate concepts. Equal DNS TTLs do not imply identical HTTP cache policy for wire and JSON representations.
- DNSSEC material is supplied when requested on native DNS and DoH, including
JSON
do=true, with supporting DNSKEY access and the same live zone/key lifecycle. Failure to construct required signatures or proofs produces failure rather than an unsigned successful answer. - Dataset payload attestation remains independent of DNSSEC.
Usage and query statistics
- Usage counts successful authenticated evaluation, exactly once, before any subsequent signing, encoding, or delivery failure. It remains estimated activity, not proof of delivery or billing-grade usage. Emission stays best-effort and off the request's network-I/O path.
- Query statistics cover native DNS and DoH and classify the query family
actually evaluated, including datasets. A hosted record named
units, for example, is a hosted-record query rather than a unit conversion. - Outcome and attribution changes require coordinated changes to the core event contract and Rails consumer. Actual response-construction and delivery failures remain separately observable through transport metrics.
Conformance and documentation
Implementation must align the protocol's detailed sections, published dashboard
documentation, Go and JavaScript SDK behavior, MCP output, living examples, and
affected Rails validation and statistics contracts. Conformance checks must
exercise the final native and HTTP responses as well as shared evaluation, so
response construction cannot hide incorrect RCODEs, cache lifetimes, or DNSSEC
material. These gates are implemented; their commands and coverage are maintained
in docs/runbooks/shared-answering-launch.md.
Required verification includes controlled expiry/window transitions, coherent
updates, signing-generation refresh and failure, authenticated negative caching,
exactly-once evaluation accounting despite later response failure, and byte-exact
SDK/MCP consumption. Query-encoding cases must vary DNS letter case while
preserving decoded bytes, and payload cases must include semicolons and padded
Base64 containing + or /. Test actual native response construction and both
DoH representations, alongside the shared module's interface.
The answering-module decision and its rationale are recorded in
docs/adr/0001-shared-uqrp-answering.md (ADR-0001).
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
| Principle | Implementation |
|---|---|
| Explicit lookup target | Identical queries select the same target regardless of source; time-varying/provider data can change |
| Explicit parameters only | Location, IP, and context MUST be provided as query parameters |
| No source IP inference | Server MUST NOT use the querier's IP for any business logic |
| Proxy-transparent | Queries through VPNs, DoH, or proxies work identically to direct queries |
Benefits
| Benefit | Description |
|---|---|
| Predictability | Same query selects the same explicit target regardless of source; provider and time-varying data can still change. |
| Cache efficiency | No ECS scope fragmentation. One cache entry can serve clients sharing the same recursive cache instance. |
| Privacy | Source IP is not substituted as query data. Direct DoH still sees the connecting IP. |
| Auditability | Inspect any query string to see exactly what data the server receives. |
| Compatibility | Works 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:
- Unpredictable results - Same query returns different data from different networks
- Proxy/VPN breakage - Queries return data for the proxy's location, not yours
- Cache fragmentation - ECS-scoped responses create thousands of cache entries per /24 subnet
- Privacy leakage - Server logs reveal your approximate location
- 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: rejectedPrivacy Best Practices
For applications handling sensitive data, ResolveDB supports multiple layers of protection:
| Layer | Feature | Description |
|---|---|---|
| Transport | DoH/DoT | Query authoritative servers via DNS-over-HTTPS to encrypt queries in transit |
| Authentication | Namespace query tokens | Use auth-rdbq... for private hosted-record reads |
| Payload encryption | AES-256-GCM | Client-side encrypt data before storing; server never sees plaintext |
| Token privacy | Encrypted transport | Use DoH or DoT and redact the complete authenticated qname |
| Namespace isolation | Private namespaces | Use 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;t=data;tc=22.2;tf=72.0;..." -------|
| | |
|-- (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" +shortNamespace 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.netHosted 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.netThe 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
| Rule | Constraint |
|---|---|
| Length | 3-32 characters |
| Characters | Lowercase a-z, 0-9, - (hyphen) |
| Start | Must start with a letter |
| End | Must end with a letter or number |
| Uniqueness | Globally 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-devCreating 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
The version and encoding rules in this section specify the shared answering contract. Encoded parameters are aligned across server, write validation, and SDKs (#45). Canonical resource versions are aligned by #44.
Structure
<operation>.<params>.<resource>.<namespace>.<version>.resolvedb.<tld>
| Component | Required | Description |
|---|---|---|
| operation | Yes | Action to perform |
| params | No | Encoded parameters |
| resource | Yes | Data resource name |
| namespace | Yes | public or a globally unique hosted namespace |
| version | Yes | Resource version, scoped to resource and namespace (for example v1) |
| resolvedb | Yes | Protocol marker |
| tld | Yes | .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 / b32-param / hex-param / latlon
; Plain parameter labels: ordered hosted keys include underscores and edge hyphens.
; Public operations impose their own semantic grammar after authorization.
plain-param = 1*63(ALPHA / DIGIT / "-" / "_")
ALPHANUM = ALPHA / DIGIT
; Encoding prefixes
b32-param = "b32-" 1*base32 ; Unpadded; canonical lowercase
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" %x31-39 *DIGIT ; Positive integer, no leading zero
tld = "net"
; Labels per RFC 1035: alphanumeric start/end, max 63 chars
label = ALPHANUM *61(ALPHA / DIGIT / "-") [ALPHANUM]
; Character classes
base32 = %x41-5A / %x61-7A / %x32-37 ; A-Z / a-z / 2-7
base32hex = DIGIT / %x61-76 ; lowercase 0-9, a-vGrammar Notes:
- All prefixes use hyphens (
-), never colons (:) - colons are invalid in DNS labels per RFC 1035 - LDH structural labels use hostname syntax; parameter labels use the broader
plain-paramgrammar above, including underscores and negative coordinates. - 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 leadingauth-param, never asplain-param; credential-shaped labels in any later position are invalid. - Retired encoded-parameter prefixes
b64-,base64-, andrdb-MUST NOT fall through toplain-param. - Normalize DNS spelling before decoding
b32-orhex-; never lowercase the decoded parameter bytes.
Operations
| Operation | Description | Auth Required | Transport |
|---|---|---|---|
get | Retrieve data | No (public) / Yes (user) | DNS |
info | Resource metadata and JSON Schema (Schema Access) | No | DNS + HTTP |
geoip | Explicit-IP geolocation | No | DNS |
manifest | Dataset manifest | No | DNS |
identity | Dataset identity fact | No | DNS |
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.
| Prefix | Encoding | Use Case |
|---|---|---|
| (none) | ASCII letters, digits, -, _ | Ordered hosted keys; public operation-specific grammar |
b32- | Unpadded Base32, canonical lowercase | Case-stable encoded parameters |
hex- | Hexadecimal | Binary hashes |
auth- | Opaque rdbq query token | Private hosted-record authorization |
Base64 is unsuitable for case-insensitive query labels. Its retirement here does not change standard padded Base64 inside TXT payloads or RFC 8484's Base64url encoding of an entire DNS message in an HTTP parameter.
b32- uses the RFC4648 alphabet a-z2-7, not the query token's 0-9a-v
alphabet. Decoding accepts ASCII letter-case variants, but requires no padding
and zero unused trailing bits. hex- requires a nonempty, even number of hex
digits. The earlier base32- spelling is also decoded, but writers emit b32-.
Both forms are subject to the 63-byte label limit. Retired b64-, base64-, and
rdb- labels are FORMERR after authorization, never plain-parameter fallbacks.
Public operations decode one label once, then interpret its text using that
operation's grammar (location, ticker/pair, conversion, date, metric, or dataset
selector). Weather, forecast and sun also accept coordinate JSON with exactly
lat and lon numeric fields. Case-sensitive JSON keys remain case-sensitive.
For example, {"lat":51.4769,"lon":0} is:
dig TXT get.b32-pmrgyyluei5dkmjogq3tmojmejwg63rchiyh2.weather.public.v1.resolvedb.net +short
# Equivalent coordinates, using the retained direct form:
dig TXT get.51d4769_0.weather.public.v1.resolvedb.net +shortDirect ip- and w3w- inputs remain supported. GeoIP's explicit-IP resource
position also accepts a Base32/hex-encoded textual IP address. Decoded text is
never parsed again as DNS labels or credentials. Hosted keys instead retain the
ordered encoded labels: b32-ie.config.acme.v1, hex-41.config.acme.v1,
and a.config.acme.v1 are different stored keys, even when decoded bytes happen
to match. Only DNS spelling case is normalized on writes and reads.
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:
- Server maintains index of
blind_token → device_idmappings - Accepts tokens for current week AND previous week (seamless rotation)
- 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:
| Property | Guarantee |
|---|---|
| Device enumeration resistance | 2^128 token space |
| Identity privacy | Device ID never in query |
| Rotation | Automatic weekly (epoch_week) |
| Factory isolation | Token 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_flagQuery Format:
get.ctp-<base64url-token>.<resource>.<namespace>.v1.resolvedb.<tld>
Example:
get.ctp-dGVzdHRva2VuMTIzNDU2Nzg5MGFiY2RlZg.dark-mode.flags.hooli.v1.resolvedb.net
Validation:
- Server decrypts token with app's registered secret
- Validates timestamp (reject if >5 minutes old)
- Evaluates targeting rules against segment bitmap
- Returns evaluated flag values, NOT targeting rules
Security Properties:
| Property | Guarantee |
|---|---|
| User identity privacy | Only 8-byte hash in encrypted token |
| Targeting rule privacy | Rules evaluated server-side |
| Cache efficiency | Same cohort (bitmap) = same cache entry |
| Replay window | 5-minute token expiry |
Error Codes:
| Code | Status | Description |
|---|---|---|
E019 | secviol | CTP token decryption failed |
E020 | secviol | CTP 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:
- Extract namespace from query FQDN
- Look up tenant's
tenant_query_keyby namespace - Recompute expected signature using extracted timestamp
- Constant-time compare signatures
- Verify timestamp within 5-minute window
- 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:
- JWT claims contain matching
tenantfield - Query namespace matches JWT tenant
- Signature is valid for query namespace
Security Properties:
| Property | Guarantee |
|---|---|
| Cross-tenant prevention | Signature cryptographically bound to namespace |
| Token theft resistance | Attacker needs query_key, not just JWT |
| Replay window | 5-minute timestamp validation |
| Bug immunity | Works even if authorization code has bugs |
Error Codes:
| Code | Status | Description |
|---|---|---|
E018 | secviol | Signature validation failed |
E021 | secviol | Timestamp outside valid window |
E022 | secviol | Namespace 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_idQuery Format:
get.auth-rdbq<52>.{resource}.{namespace}.{version}.resolvedb.net
get.auth-rdbq<52>.{params...}.{resource}.{namespace}.{version}.resolvedb.netEnforcement (nodes with record sync enabled):
publicnamespace: no token required.- Public-read namespaces: a namespace the operator has flagged
public_read=true(synced viasync.v1, see below) is answered tokenless and UNMETERED, exactly likepublic. This is how thehoolidemo namespaces are served once migrated offDEMO_SEEDto 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-switchPUBLIC_READ_DISABLED=trueneutralizes the public-read branch without a Rails round-trip. - Demo namespaces (
hooli,hooli-staging,hooli-dev,demo): answered only when the node runs withDEMO_SEED=true; REFUSED otherwise. This is the reversible safety net retained alongside (2) during the migration. - 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. - Namespace labels are ASCII-lowercased before matching; mixed-case queries behave identically to lowercase ones.
- 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:
| Concept | Field | Meaning | Default |
|---|---|---|---|
| Storage lifetime | expires_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 TTL | DNS RR TTL | How 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 hint | ttl= (in the rdb1 TXT payload), sourced from ttl_seconds for hosted records | Application-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) orexpires_inseconds-from-now (0clears expiry, making the record permanent again). - The configured
ttl_secondsand rendered envelopettl=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 been reached (expires_at <= now) — persistent
(null) records are never expired.
Coherent hosted observations — implemented (#34). Sync publishes each
record's rendered bytes, namespace, owning customer, and expiry together as one
immutable in-memory value. Native DNS and both wire/JSON DoH read that same
per-record observation through the shared answering module's sync reader. A query overlapping create,
replacement, deletion, or snapshot reconciliation can observe a complete earlier
or newer state; it does not join new bytes to old expiry metadata. This adds no
global snapshot across records and does not change sync.v1 or Rails/Postgres
as the system of record.
Expiry cleanup removes only the exact observed publication: a renewed/replaced record cannot be erased by an older in-flight read. Sync's bookkeeping index is used for reconciliation and hourly sweeps, not eligibility. After query-time cleanup, its record count may include that reclaimed entry until the writer's next sweep/delete/reconcile removes the bookkeeping entry.
Authorization precedes the observation. A privately authorized read retains the verified namespace owner, authorizing token digest, and private cache policy through missing, expired, and storage-failure outcomes. Native and DoH expiry statistics therefore retain verified tenant attribution; misses and expiry still emit no successful-query usage. Public-read/demo observations carry no private attribution even when the stored record has an owner. Record identity must match the authorized owner; deleting or changing a namespace owner retires that name's previous tokens before they could authorize the replacement owner's records.
Each namespace's owner, public-read policy, and token set also publish as one immutable authorization generation. Hosted reads pin that generation through token verification and storage observation, then reject a changed generation before serving or reclaiming the value. This prevents a previous public-read decision from exposing newer private bytes, and prevents a newly installed token from being paired with an earlier owner. Identical ACL reconciliation preserves the generation; unrelated namespaces do not invalidate the read. The sync writer continues to apply ordered events, snapshots, and sweeps serially.
Shared hosted lifecycle — implemented (#41). Native UDP/TCP, dnsdist DoT,
wire DoH GET/POST, and both JSON entry forms use one evaluator/assembler. After
structural identification, namespace/product policy and authorization precede
semantic validation, answer-type policy, and storage observation. Invalid tokens
win over bad encoded parameters. Token expiry is rechecked after observation.
Hosted resource versions use canonical v[1-9][0-9]*; ordered encoded key labels
remain literal identity (Base32/hex decoding never creates storage-key aliases).
Retired Base64-derived query prefixes are malformed after authorization.
Hosted TXT is the rendered ordinary success envelope; corrupt UTF-8/envelopes or lookup failures produce SERVFAIL, denial is uniform REFUSED, malformed input is FORMERR, and missing/expired values share NOERROR absence. Other product qtypes, including NULL, yield no value and no successful-evaluation usage.
Public positive DNS TTL is bounded by the observed expiry, aged to assembly and
rounded down to whole remaining seconds. Private positive and negative TTLs are
zero, including SOA TTL/MINIMUM and denial-proof/signature TTLs; public negative
caching retains the zone policy. Payload ttl= is unchanged. JSON is always
HTTP no-store; wire DoH uses effective answer TTLs and is no-store for private,
empty, or failed responses. One generation supplies requested signatures/proofs;
required signing failure returns SERVFAIL. An authenticated eligible evaluation
attempts usage exactly once before assembly; a later signing/encoding/delivery
failure neither retracts nor repeats that bounded, nonblocking attempt.
Security Token Summary
| Prefix | Use Case | Key Derivation | Expiry | Error Codes |
|---|---|---|---|---|
bdt- | IoT device identity | HKDF from factory secret | Weekly rotation | E018 |
ctp- | User targeting | AES-256-GCM with app secret | 5 minutes | E019, E020 |
sig- | Multi-tenant auth | HMAC-SHA256 with tenant key | 5 minutes | E018, E021, E022 |
auth-rdbq… | Private namespace reads | 32 random bytes, base32hex; SHA-256 digest synced | ≤365 days, revocable | rcode 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. An initial connection failure makes
the meter a no-op until restart; failures after initialization are best-effort
publication errors with connection-manager recovery for subsequent commands.
Consequently the pipeline is lossy by design and counts are an estimate, not a
guarantee.
Redis publication policy (both telemetry writers). Each writer sends an
application-level PING every 60 seconds while idle, below managed Valkey's
300-second idle timeout. Busy XADD traffic also prevents idle expiry. Each
connection attempt and command reply has a 2-second timeout; the overall
connect/XADD/PING wait (including reconnect) is capped at 5 seconds. Connection
retries are limited to two, with at most 1 second between attempts. Missed PING
ticks do not burst. The existing bounded queues (10,000 usage / 20,000 query-stat
events) and stream caps remain in force; all network I/O stays in the background.
Closing the channel drains queued events and exits the writer; process termination
can still lose queued events.
Timed-out connections are discarded and lazily reconnected for subsequent
commands so cancelled multiplexed requests cannot accumulate pending replies.
Every event gets one XADD attempt, never a blind retry: an error or timeout
may follow a committed append, and XADD * would assign a new ID on retry.
resolvedb_usage_publish_errors_total / resolvedb_query_stat_publish_errors_total
count failed or timed-out attempts, not proven lost events.
resolvedb_usage_keepalive_errors_total / resolvedb_query_stat_keepalive_errors_total
count failed or timed-out PINGs, including during idle outages. These four counters
have no labels, are initialized at zero, and are republished every 30 seconds.
The first failure of each kind logs once per writer. Existing *_events_dropped_total
counters measure only full/closed-channel shedding, not network health.
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 statistics: 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|dohPrivacy 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.v2, staged v1 compatibility)
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 initialized) makes a silent
disable (e.g. a per-host RESOLVER_REGION cleared by an ops change while Redis
stays configured) alertable rather than invisible. It is initialization state,
not live Redis health; monitor the publication/PING error counters defined above.
Single evaluation emit. Shared answering emits once at evaluation completion,
before assembly, for all hosted/schema/public/dataset families on native DNS
(UDP/TCP, including the DoT backend) and wire GET/POST and both JSON DoH forms.
Hosted resources are classified as records even when named units, weather,
or another public resource. Dataset manifests, keys and identities use datasets.
Apex/NS/static queries, foreign-zone requests, admission denials and undecodable
transport messages do not evaluate a product and emit no product statistics.
Signing, encoding, truncation, HTTP/send errors remain transport observations;
they neither repeat nor retract the completed evaluation's statistics or usage.
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
schema=query_stats.v2 # REQUIRED on revised producers; absent means legacy v1
resource=<closed class> # weather|forecast|stock|forex|crypto|geoip|units|sun|moon|btc|records|schema|datasets
version=<v[1-9][0-9]*> # <=63 bytes; omitted for malformed versions on error/refused
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 / service error) → error. Shared hosted unsupported qtypes are miss
without a storage evaluation; semantic errors retain precedence and are error.
Recognizable malformed product requests (for example get.bad.resolvedb.net)
emit error with the selected family (records for the hosted/default
evaluator). When the fixed suffix cannot identify a canonical resource version,
the version field is absent. Invalid query versions are likewise absent, while
canonical versions remain present even for denied queries. Ordinary unknown
zone names are not product evaluations. refused and error are distinct: refused is
an authz denial (deny-by-default, missing/invalid token), error is a true
failure.
Authorized hosted misses and expiry retain customer_id/namespace from the
verified token gate. Public-read/demo expiry remains NULL-tenant; stored-record
ownership alone never supplies query attribution. Only a hit emits usage.
Consumer-first compatibility (#46 release correction). The production
baseline 78a7ddb5889bee489dd6459a2f292ffe90abd777 emits type=query_stat with
NO schema field. That is the distinct legacy v1 contract, not an alternate
spelling of v2:
| Property | Legacy v1 (schema field absent) | Revised v2 |
|---|---|---|
| Resource | Closed list above excluding datasets, classified from operation/resource labels | Actual evaluated family, including datasets |
| Version on wire | Required [a-z0-9]{1,16}; producer substitutes v0 for invalid/long labels | Canonical v[1-9][0-9]*, <=63 bytes; absent only on error/refused |
| Private attribution | Ownership-checked customer/namespace on any legacy class (private units/weather were labelled that way) | Ownership-checked records only |
| Stored provenance | event_contract=query_stats.v1 | event_contract=query_stats.v2 |
Both use the same five statuses, three regions, bounded namespace and positive
customer identity check. Legacy v0 is ambiguous (sentinel or old spelling):
store it, and every noncanonical legacy version, as NULL on any status.
Preserve canonical legacy versions exactly. Preserve the legacy resource class
without pretending to reconstruct the evaluated family. Existing rows retain
their raw values in PostgreSQL; query-log serialization exposes only canonical
versions or null, plus event_contract. The UI marks legacy label classes.
No qname, parameters, token, address or organization identity is added.
QUERY_STATS_ACCEPT_LEGACY=true enables dual-contract ingestion as an explicit,
temporary override for Rails-first/node-overlap/backlog drain. Both application
and committed deployment defaults are false (v2 only), so ordinary subsequent
deploys remain strict. Only exact true/false values boot.
The flag affects new stream ingestion, never visibility/validation of
retained legacy rows. v2 validation remains strict even while compatibility is on.
Any present schema other than query_stats.v2 (including query_stats.v1, empty
or null) is unsupported. An unsupported schema, or an unmarked event in strict
mode, raises a redacted contract error and rolls back that entire batch without
advancing the watermark. The recurring consumer reports the closed reason and
stream ID, never event fields, and re-raises. Re-enable compatibility to recover
late v1 events; do not skip/reset the cursor. Already-consumed IDs remain no-ops
even after strict cutover. Malformed known-contract tuples are skipped and
counted as before; enums, field names, versions, namespace and numeric bounds are
validated before conversion. Unknown schema versions are never treated as v1.
Apply both nullable-version and additive event-contract migrations before new
consumers. The latter uses a constant v1 DB default for existing rows/old writers,
a short lock timeout, and no historical rewrite. Fully replace old consumers
before any v2 producer starts. After every old producer retires, capture a stream
tail fence, drain through it and verify quiescence, then promote strict mode.
Detailed two-phase API deployment (managed DB connection limit), verification and
rollback: docs/runbooks/query-stats-launch.md. Retain the additive schema and
dual-capable consumer on rollback.
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). For v2, namespace_id is set ONLY for records 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 both contracts (no hot-path timing).
Organization scope is derived in Rails through the verified namespace; neither
organization_id nor a transport/source address is added to either contract.
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.
The additive event_contract field distinguishes legacy label classes from
revised evaluated families. Version null on legacy hits is unknown identity,
not an invented modern v0 resource version.
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 reserveddatasetsresource 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+provenanceare non-null on every dataset and shipped seed data is labeled SYNTHETIC.
Two signature layers (never conflated)
- 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.
- 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 contentField 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, UTF-8, 255-byte TXT chunking as needed):
v=rdb-attest.v1;ds=bin-acme;name=Acme BIN;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 byte bounds (also enforced by Rails): ds ≤ 40, name ≤ 255, 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).
ds is the DNS slug; name is the display name used in the canonical signed
tuple. They need not be equal. The additive sync name field preserves the
existing signature bytes and makes the tuple reconstructible. Deploy Rails
before core and complete a refreshed dataset snapshot; a manifest missing its
signed name is not silently assigned the slug. Publisher edits to signed
dataset fields are rejected while an attested version exists.
Attestation keys (well-known manifest.keys.datasets.public.v1):
v=rdb-attest-keys.v1;k-ak1=<standard-padded-base64 raw 32-byte Ed25519 public key>
This closed envelope contains one to sixteen k-<kid> fields, with unique
1–16-character ASCII letter/digit/underscore/hyphen key IDs. No ordinary metadata,
manifest fields or identity facts are added. Configure core at startup with
DATASETS_ATTEST_PUBKEYS, a JSON object mapping key IDs to those base64 values.
Unset/empty-map discovery is NODATA; malformed/oversized configuration fails
startup. This is public verification material, never an attestation private key
or a DNSSEC DNSKEY. SDKs decode it without claiming verification. Pinned
keys-v-... forms have no release and yield absence.
Identity:
v=rdb-id.v1;ds=bin-acme;k=411111;issuer=Acme Bank;brand=visa;cc=US;type=credit
Publisher facts are a JSON object of allowed string-valued fields, carried
unchanged into sync.v1. Non-string values (including JSON booleans, numbers,
nulls, arrays, and objects) are rejected, not implicitly converted to text.
Attestation revalidates stored identity rows before signing or emitting events.
Bulk draft writes, version updates, and submissions lock and recheck the version
inside their transaction, so stale requests cannot change a release after
concurrent submission or attestation.
Release rejection and deletion serialize with attestation on the dataset row
before choosing the surviving latest manifest. Version, dataset, and customer
deletion all emit withdrawal events in the deletion transaction: temporal
identity sets exclude removed windows (empty when none survive). Legacy mode
republishes a surviving representative using the same selection as snapshots,
or deletes the identity when none survives; affected keys are captured before
dependent rows disappear. A rollback discards both deletion and outbox events.
Fact values exclude TXT delimiters and Unicode control characters; the complete
identity envelope must fit the fleet's 3500-byte rendered-value limit.
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
- Validate the DNSSEC chain on the TXT answer (DNS answer authenticity and integrity).
- Rebuild the canonical manifest tuple from the served fields and verify
attsigwith the attestation public key forattkid. Public keys are distributed via the DNSSEC-signed well-knownmanifest.keys.datasets.public.v1.resolvedb.netTXT and the docs. - Fetch the
urlbulk content out of band and confirm its SHA-256 equalssha256(the URL is untrusted; onlysha256is authoritative).
Status / error mapping
| Condition | Response |
|---|---|
resource=datasets with op ∉ , or namespace ≠ public, or version ≠ v1 | REFUSED |
| Malformed slug / version / item key | FormErr |
| Well-formed but unknown slug / version / key | negative (NODATA — this zone never returns raw NXDOMAIN by design) |
| Attested manifest present | rdb-attest.v1 TXT preserving name, license/provenance, and opaque attestation |
| Identity present | rdb-id.v1 TXT with item facts (+ requested RRSIG); provenance is supplied by the manifest, not an individual item signature |
| Configured attestation keys present | rdb-attest-keys.v1 TXT, distinct from zone DNSKEY |
DATASETS_ENABLED off | REFUSED |
Shared manifest/keys responses preserve FORMERR/REFUSED/SERVFAIL through final
native and DoH assembly. Corrupt stored envelopes and failed reads are SERVFAIL,
not absence. Product queries are TXT-only; after policy and parameter validation,
other qtypes yield NODATA. Requested DNSSEC signatures/proofs fail closed and do
not assert AD. Public positive TTL is 3600 seconds, wire DoH uses max-age=3600,
and JSON/errors/empty wire answers use no-store. No publisher/customer ownership
is attributed to the public reader and these evaluations emit no private usage.
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 underDATASETS_ENABLED(both ends must be on). When off,-t-returns FORMERR and identity replicates as a single current fact exactly as before. Manifests are immutable bysha256and 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 foridentity; manifests reject it. asofis EITHER unix seconds (1–10 digits,0 ≤ v ≤ 253402300799) OR an exactYYYY-MM-DDcalendar 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. Absentvalid_from= −∞, absentvalid_to= current/open. No-t-⇒asof = now(server clock). The server never serves a window withvalid_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 (SERVFAIL).
- Publisher window bounds are whole Unix seconds in
0..253402300799(or null for an open end). Attestation refuses a release that would exceed 64 windows for any one item, under the same per-item lock; revoking a window frees room.
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)Implemented coherence prefactor (#32): these logical keys are stored in one immutable per-item publication. Native DNS and DoH pin the complete item once, select the unique window, and render its retained fact bytes. Event updates and snapshot reconciliation prepare the entire replacement before publishing it; removal and orphan cleanup cannot invalidate an observation held by a reader. Unrelated items publish independently, with no whole-store snapshot. Dataset ownership remains separate from hosted records, and all identity keys must pass the reserved-key fence and belong to the same item. An invalid, over-count, or oversized replacement leaves the earlier complete item available.
Record events cannot write or delete reserved dataset keys. Legacy snapshot identity bodies must match their outer item owner. An unidentifiable snapshot owner defers identity removals until a usable inventory arrives; valid item updates and the independent manifest/record stages can still proceed.
Shared identity answering — implemented (#43): current and explicit as-of
reads use the same evaluator/assembler on UDP/TCP, dnsdist DoT, wire DoH and both
JSON forms. An absent tindex permits a legacy single-key value only for a
current query from the same coherent observation. Explicit history requires
actual index/window evidence; a legacy-only item returns empty NOERROR.
An explicit ninf,cur window is evidence and remains supported.
Missing items and valid windows with no eligible fact return empty NOERROR.
Malformed/overlapping indexes, owner mismatches, missing or invalid indexed facts,
orphaned facts, unexpected stored keys and retrieval failures return SERVFAIL.
Every indexed fact is checked before selection, including for queries in gaps.
Invalid publications retain the complete previous item rather than exposing a
partially rendered fact/window set. Requested signatures and denial proofs use
one serving generation; required signing failure remains SERVFAIL.
Current positive TTLs stop at the selected window's end; current gap negatives
(SOA TTL, SOA MINIMUM and signed proof TTL) stop at the next window start.
Remaining lifetime is aged before assembly and rounded down. Explicit as-of
selection is fixed in time, so wall-clock transitions do not change its fact;
settled history retains the seven-day policy above. Wire DoH uses the effective
positive TTL; empty/error responses use no-store, as do all JSON responses.
Identity payloads remain v=rdb-id.v1;ds=...;k=...;<facts>, with the publisher's
closed BIN/GTIN fields. License/provenance and the independent attestation
signature remain in the dataset manifest; identity is not an individually signed
manifest or an ordinary rdb1 envelope. Public identity reads carry no private
attribution or authenticated usage. Dataset evaluation emits the explicit
Rails-accepted datasets class across native DNS and DoH (#46), verified through
captured streams and Rails ingestion/readers. Canonical versions and encoding are implemented as
documented under Query Format and Parameter Encoding.
Conformance: just client-conformance includes actual identity responses through
Go/JS/MCP. just identity-publisher-conformance owns a disposable local Postgres
and exercises Rails attestation/revocation, real sync endpoint serialization,
HTTP snapshot/event ingestion, native/HTTP answering and client consumption.
Financial Services (stock, forex, crypto)
Shared financial answering — implemented (#39). Native DNS (including the
TCP backend used by dnsdist DoT), wire DoH GET/POST, /resolve, and JSON
/dns-query?name= use the same evaluator and DNS assembler. Public financial
lookups support get, namespace public, and resource version v1. Credentials
and unsupported operations/versions are REFUSED before parameter evaluation;
malformed version spellings and malformed parameters are FORMERR. Financial
resource names in other namespaces remain hosted-record identities and use their
own namespace authorization, not public provider dispatch.
dig TXT get.AAPL.stock.public.v1.resolvedb.net
dig TXT get.USD-EUR.forex.public.v1.resolvedb.net
dig TXT get.BTC-USD.crypto.public.v1.resolvedb.netOrdinary successes begin v=rdb1;s=ok;t=data, with the existing flat fields and
ttl/ts metadata. Forex preserves from, to, rate, optional bid/ask;
crypto preserves sym, cur, prc, optional chg24/pct24/vol24/cap.
Stock fields and reference provenance are detailed below. Provider connectors,
fallback order, and missing-symbol short-circuit behavior are retained.
| Evaluation | Final DNS outcome | SDK/MCP outcome |
|---|---|---|
| Eligible quote/rate | NOERROR + one TXT RR | Ordinary/typed value |
| Symbol absent | NOERROR with no answer | Not found |
| Malformed input or failed explicit MIC | FORMERR | Invalid query |
| Product/credential denial | REFUSED | Refused |
| Provider unavailable, rate-limited, or failed | SERVFAIL | Server error |
Valid non-TXT financial queries yield NODATA without contacting a provider;
parameter validation and policy still precede that answer-type rule. Requested
DNSSEC includes signatures/proofs, never asserts AD, and fails with SERVFAIL if
required signing cannot complete. Public financial evaluation records the actual
stock/forex/crypto class on native DNS and DoH and never emits private usage;
later signing failure remains separate from the evaluation outcome.
Stock DNS TTL remains 60 seconds during regular US market hours, 300 during
pre-/after-market, and 3600 when closed. Forex uses 60 while open and 3600 while
closed; crypto uses 60 continuously. The payload ttl= is a hint, distinct from
effective RR TTL and HTTP cache policy. Wire DoH uses effective positive TTLs for
max-age; empty/error responses are no-store. JSON always uses no-store.
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.netThe 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:
| Field | Meaning |
|---|---|
exchref | ISO-10383 MIC of the LISTED primary exchange (reference, not venue) |
exchsrc | Provenance tag of the reference dataset (synthetic-ref in the shipped fixture) |
exchasof | UTC 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. Cached prices also recheck reference
freshness: a still-cached quote cannot keep stale provenance or bypass a stale
explicit-MIC refusal.
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. Native DNS and both DoH representations expose this as FORMERR
with no financial TXT answer; no detailed error envelope is emitted. E005 is
the library error vocabulary, not an extra DoH payload. /query remains a
non-resolving placeholder.
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
# Explicit coordinates (decimal points become d)
get.40d7128_-74d0060.weather.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.netUnits 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):
dis the decimal point (1d5=1.5,0d001=0.001,d5=0.5).- A single leading
nis a negative sign (n40=-40). - Only digits and one
dmay follow; exponents, embedded signs, and whitespace are rejected. The value is parsed tof64and 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.
| Category | Base | Slugs |
|---|---|---|
| temperature | kelvin (affine) | c (celsius), f (fahrenheit), k (kelvin) |
| length | metre | m, km, cm, mm, mi (mile), yd (yard), ft (foot), in (inch) |
| mass | kilogram | kg, g, mg, t (tonne), lb (pound), oz (ounce) |
| volume | litre (US customary) | l, ml, gal (us-gallon), qt (us-quart), floz (us-fluid-ounce) |
| speed | metre/second | ms (m/s), kmh (km/h), mph (mi/h), kn (knot) |
Response fields (TXT, plain)
The response begins v=rdb1;s=ok;t=data, followed by the existing flat
key=value fields. There is no d= payload or added encoding/format/TTL hint.
Results are rounded to 6 significant figures and rendered
without trailing zeros (so 212, not 212.0000).
| Field | Meaning | Example |
|---|---|---|
in | Input value as parsed | 100 |
from | Canonical name of the source unit | celsius |
to | Canonical name of the target unit | fahrenheit |
r | Conversion result (6 sig figs, trimmed) | 212 |
cat | Unit category | temperature |
# 100 C -> F
dig TXT get.100-c-to-f.units.public.v1.resolvedb.net +short
# "v=rdb1;s=ok;t=data;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
# "v=rdb1;s=ok;t=data;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
# "v=rdb1;s=ok;t=data;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
# "v=rdb1;s=ok;t=data;in=60;from=mile-per-hour;to=kilometre-per-hour;r=96.5606;cat=speed"Error contract
| Condition | Outcome |
|---|---|
Missing/empty params, no -to-, empty value/unit, overlong label (>64) | FormErr |
| Value not parseable / NaN / Inf / exponent / stray sign | FormErr |
| Unit slug not in the closed table | FormErr |
from and to valid but in different categories | FormErr |
All malformed/cross-category inputs produce FormErr on native DNS and DoH.
On the identifiable public lookup, credentials (in any parameter position) and
unsupported operations produce REFUSED before semantic validation. info
uses the separate schema path. Semantic validation precedes the TXT-only rule:
a valid non-TXT lookup returns NODATA, while invalid parameters still produce
FormErr. A non-public namespace selects hosted authorization/lookup rather than
unit conversion, including a hosted record named units.
Units, moon, and enabled BTC share response assembly and requested DNSSEC.
Required signing/proof failure produces SERVFAIL, never unsigned success;
signatures alone do not set AD. JSON HTTP responses use no-store; wire DoH
successes use the effective answer TTL, with errors/empty answers no-store.
Their evaluation metadata identifies the actual selected resource and carries no
tenant attribution or authenticated usage. Native and DoH evaluation statistics
are implemented (#46). Public resource-version enforcement selects these services' documented
v1 contract. Base32/hex inputs use the same operation grammar.
Weather and Forecast Services
These tokenless public.v1 resources use the shared answerer on native DNS,
DoT, wire DoH, and both JSON DNS routes. The lookup target is always explicit:
dig TXT get.london.weather.public.v1.resolvedb.net +short
dig TXT get.51d4769_-0d0005.forecast.public.v1.resolvedb.net +short
dig TXT get.ip-8-8-8-8.weather.public.v1.resolvedb.net +short
dig TXT get.w3w-filled-count-soap.forecast.public.v1.resolvedb.net +shortCity names, bounded coordinates (d decimal, _ separator, optional leading
minus), explicit IPv4/IPv6, and what3words use the same interpretation for weather,
forecast, and sun. Integer coordinates such as 0_0 are valid. what3words still
requires the existing configured API key. Admission source identity and ECS never
supply an omitted target. Base32/hex parameters also support these direct text
grammars and coordinate JSON, as documented under Parameter Encoding.
| Resource | Flat fields after v=rdb1;s=ok;t=data; | Effective DNS TTL |
|---|---|---|
weather | ttl, ts, loc, tc, tf, cnd, wnd, optional hum, d1–d3 as low/high/condition | Up to 300 seconds; midnight-capped when daily fields are present |
forecast | ttl, optional ts, loc, available d0–d3 as low/high/condition/precipitation-percent | Up to 1800 seconds, capped at UTC midnight |
Daily forecasts are the provider's UTC-day values: d0 is today, d1 tomorrow.
Missing required samples omit that day; a missing precipitation probability leaves
the fourth slash-separated component empty. Forecasts remain eligible when current
measurements are absent. No eligible days means NODATA. They are never approximated
from current temperature. Optional ts is the provider's current observation time
in Unix seconds, not a fabricated generation timestamp.
The payload ttl remains a hint from service cache configuration, distinct from
the effective RR TTL. Forecast output has no current-weather tc/tf fields.
Cached daily observations are projected to the evaluation's UTC day; past days
drop out instead of becoming today's d0. Answers carrying daily fields have
their DNS/HTTP lifetime capped at the next UTC midnight.
Missing/malformed locations return FORMERR; valid unknown locations or no
eligible forecast return empty NOERROR. Forbidden credentials, operations, and
unsupported resource versions return REFUSED; malformed version spellings return
FORMERR. Provider/network failures and inconsistent provider data return
SERVFAIL. These distinctions survive DNS construction; private hosted records
named weather/forecast/sun still use hosted authorization and storage.
Requested DNSSEC signatures/proofs are supplied; required-signing failure is
SERVFAIL, never unsigned success. Wire DoH caches positive responses using the
effective answer TTL; empty/error responses and all JSON DNS responses use HTTP
no-store. Public calls are unmetered for authenticated-query usage.
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>withdas 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.
| Field | Meaning | Example |
|---|---|---|
rise | Sunrise (UTC) | 2024-06-21T03:43:00Z |
set | Sunset (UTC) | 2024-06-21T20:21:00Z |
noon | Solar noon (UTC) — always present | 2024-06-21T12:02:00Z |
dawn | Civil dawn (sun at -6°) | 2024-06-21T02:45:00Z |
dusk | Civil dusk (sun at -6°) | 2024-06-21T21:19:00Z |
daylen | Day 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
# "v=rdb1;s=ok;t=data;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)
# "v=rdb1;s=ok;t=data;polar=night;noon=...;daylen=0h0m"Error contract
| Condition | Outcome |
|---|---|
| Missing/empty params | FormErr |
| Invalid coordinates / invalid input / private IP | FormErr |
| City/what3words not found, or IP has no coordinates | NODATA |
| Backend geocode failure | ServFail |
Only TXT is answered after authorization and semantic validation. The public
service requires get and v1; private names belong to hosted evaluation. Shared
assembly prefixes ordinary metadata and caps the 3600-second policy at the next
UTC midnight, including time spent assembling the response. No payload TTL hint
is added to sun fields.
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 dateThe 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.
Noon UTC represents the chosen date; the default date changes at UTC midnight.
Response fields (TXT, plain)
The ordinary prefix v=rdb1;s=ok;t=data precedes these flat fields, with no
d= payload or added encoding/format/TTL hint.
| Field | Meaning | Example |
|---|---|---|
phase | Phase name (one of the 8 canonical phases) | Waxing Gibbous |
illum | Illuminated fraction of the disc, 0.000..1.000 | 0.787 |
age | Age in days since the last new moon (1 decimal) | 10.3 |
next_full | Date of the next full moon (UTC, YYYY-MM-DD) | 2024-03-25 |
next_new | Date 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
# Representative shape; values depend on today's UTC date:
# "v=rdb1;s=ok;t=data;phase=Waxing Gibbous;illum=...;age=...;next_full=...;next_new=..."
# A specific date
dig TXT get.2024-01-25.moon.public.v1.resolvedb.net +short
# "v=rdb1;s=ok;t=data;phase=Full Moon;illum=...;age=...;next_full=...;next_new=..."Error contract
| Condition | Outcome |
|---|---|
Malformed / impossible date label (e.g. 2024-02-30, 2024-1-1) | FormErr |
No params (today) and a valid date both succeed. The public-policy and
semantic-before-qtype precedence is the same as units above. The effective DNS
TTL remains 1 hour (TTL_OP_MOON) for both explicit and default dates.
Reserved resource —
btc(NOT yet public). Abtcchain-stats resource (get.<metric>.btc.public.v1.resolvedb.net, metricsheight/fees/mempool/halving/difficulty) exists in the reference implementation but ships gated OFF behindBTC_SERVICE_ENABLED(defaultfalse). While off, the resource behaves as unknown (NODATA) and leaks nothing. Even when enabled it currently serves mock data — every response is taggedsrc=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.
BTC now uses the same public-policy and shared assembly path as units/moon.
Valid enabled answers begin v=rdb1;s=ok;t=data, preserve all metric fields and
src=mock, and have effective DNS TTL 60. Enabled missing/unknown metrics are
FormErr even for non-TXT qtypes. With the gate off, metric validation is skipped
and the result is NODATA, but credentials/unsupported operations remain REFUSED.
This migration adds no live provider and does not enable the gate.
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 +shortHTTP 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;e=plain;f=json;ttl=3600;d={"$id":"https://resolvedb.net/schema/public/weather/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.
Schema discovery and configured static zone controls now use the shared
admitted-request-to-complete-response operation on native DNS and wire/JSON DoH
(#36). d= contains only the selected JSON Schema; the richer /schema HTTP
document above remains a separate representation. Exact schema absence is
NOERROR/NODATA with SOA, private-schema policy is REFUSED, malformed schema
queries are FORMERR, and oversized schemas are SERVFAIL. Those RCODEs survive
native encoding. Requested DNSSEC adds signatures and negative NSEC proofs;
do=true works on both JSON entry forms. Signing/proof failure is SERVFAIL,
and signing does not set AD.
curl 'https://doh.resolvedb.io/resolve?name=info.weather.public.v1.resolvedb.net&type=TXT&do=true'
curl 'https://doh.resolvedb.io/dns-query?name=resolvedb.net&type=DNSKEY&do=true'Wire DoH successes use effective answer TTLs for max-age; empty/error answers
use no-store. JSON always uses no-store. All public, hosted, dataset/temporal
and schema reads use the same complete answering interface.
Native and DoH schema statistics are retained once at
evaluation completion. just schema-conformance consumes real HTTP schema
responses with both SDKs, in addition to the Rust native/HTTP response tests.
just client-conformance includes that gate and captures schema/control/hosted outcomes
from real native UDP/TCP, wire DoH GET/POST, and both JSON entry forms. Go replays
the actual wire responses through TLS DoH and UDP/TCP fallback; JavaScript and
MCP consume them through the worker-local SDK. Missing and private schemas,
malformed queries, oversized schemas, and controlled NSEC construction failure
retain their result distinctions through client/tool boundaries.
Hosted cases include private/public-read payloads, semicolons, standard padded
binary Base64, expiry, misses, denial, and corrupt-state failure. The explicit
Linux DoT gate builds dnsdist/Dockerfile, then runs
DNSDIST_TEST_IMAGE=<local-image> cargo test --locked -p resolvedb-core --lib hosted_dnsdist_dot_preserves_lifecycle_and_negative_lifetime -- --ignored.
Access Control
| Namespace | Auth Required | Notes |
|---|---|---|
public | No | All public schemas freely accessible |
<hosted namespace> | Not exposed | Production 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.
The implemented contract standardizes ordinary successes on v=rdb1;s=ok;t=data,
retaining flat resource fields where applicable. If present, d= is last and
consumes the remainder, and e=b64 denotes standard padded Base64. Hosted
write/sync/read and Go/JavaScript/MCP payload consumption implement this grammar;
public successes also use the ordinary prefix. Dataset envelopes stay separate.
Join the character-strings of one TXT RR, decoding DNS presentation escaping
when reading text/JSON rather than wire bytes. UTF-8 characters may cross string
boundaries. Interpret metadata only before d=; its remainder is verbatim,
including semicolons, equals signs, quotes, backslashes (even \;), and UTF-8.
For example v=rdb1;s=ok;t=data;e=plain;ttl=300;d=hello;s=error has status ok
and payload hello;s=error. Flat ordinary fields retain their existing meaning.
Sync renders valid UTF-8 without quotes/controls as plain data; quoted data,
controls and arbitrary binary use standard padded Base64 (+/8= decodes to
bytes fb ff). Nonstandard alphabet, missing/extra padding, and nonzero pad bits
are malformed. Binary source data is valid; invalid UTF-8 in a stored rendered
envelope is corruption, not data to repair with replacement characters. Shared
answering returns SERVFAIL on every transport, never cacheable absence.
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>
| Field | Description | Values |
|---|---|---|
v | Envelope format identifier | rdb1 |
s | Status code | See status codes |
t | Response type | data, url, multi, stream, encrypted |
e | Encoding | plain, b64, b32, hex, compressed, encrypted |
f | Format | json, xml, protobuf, msgpack, text, binary |
c | Chunk info | current/total (e.g., 1/3) |
h | SHA-256 hash | First 16+ chars |
ttl | Application-visible configured TTL hint | Seconds; DNS resolver caching is controlled independently by the RR TTL |
sig | Ed25519 signature | Base64 encoded |
seq | Sequence number | For ordering multi-part |
ts | Timestamp | Unix epoch |
err | Error code | Machine-readable error (e.g., E001) |
retry | Retry after | Seconds until retry is appropriate |
d | Terminal data payload | Entire remaining value, decoded according to e |
Status Codes
| Code | HTTP Equiv | Description |
|---|---|---|
ok | 200 | Success |
partial | 206 | Partial content (chunked response) |
redirect | 301 | See URL in data |
notfound | 404 | Resource not found |
auth | 401 | Authentication required |
forbidden | 403 | Access denied |
ratelimit | 429 | Too many requests |
invalid | 400 | Malformed query |
toolarge | 413 | Response exceeds limits |
secviol | 400 | Security violation (signature invalid, replay detected) |
error | 500 | Server error |
unavail | 503 | Service unavailable |
Error Codes
Machine-readable error codes for programmatic handling:
| Code | Status | Description | Retryable | Recovery Strategy |
|---|---|---|---|---|
E001 | invalid | Malformed query syntax | No | Fix query format |
E002 | invalid | Unknown operation | No | Use valid operation |
E003 | invalid | Invalid encoding prefix | No | Use the documented b32- or hex- query form |
E004 | notfound | Resource does not exist | No | Check resource path |
E005 | notfound | Namespace does not exist | No | Register namespace first |
E006 | auth | Missing authentication | No | Include auth-<token> |
E007 | auth | Token expired | No | Refresh token |
E008 | auth | Token invalid | No | Check token format/signature |
E009 | forbidden | Insufficient permissions | No | Request access grant |
E010 | ratelimit | Rate limit exceeded | Yes | Wait for retry seconds |
E011 | toolarge | Rendered value exceeds the DNS envelope | No | Reduce the decoded payload below 2,586 bytes |
E012 | error | Internal server error | Yes | Retry with backoff |
E013 | unavail | Service temporarily unavailable | Yes | Retry 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 LLCDNS over HTTPS (DoH)
ResolveDB supports DNS over HTTPS per RFC 8484, providing encrypted DNS queries via HTTP/S.
Endpoints
| Endpoint | Method | Format | Description |
|---|---|---|---|
/dns-query | GET | Wire or JSON | ?dns= → RFC 8484 wire; ?name= → JSON (shared with /resolve) |
/dns-query | POST | Wire | RFC 8484 with application/dns-message body |
/resolve | GET | JSON | Google-style JSON API for browser/debug use |
Content Negotiation & Precedence (/dns-query GET)
/dns-query GET is param-authoritative and deterministic (Cloudflare-compatible):
dns=present → WIRE (RFC 8484, unchanged bytes/headers). If BOTHdns=andname=are present, WIRE wins — never an error.- else
name=present → JSON resolve (the SAME path as/resolve). - neither → a fully-static FORMERR 400 (
Status:1,Commenta 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.binResponse:
- Content-Type:
application/dns-message - Body: DNS wire format response
- Cache-Control:
max-age=<min-TTL>orno-storefor 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:
| Parameter | Required | Default | Description |
|---|---|---|---|
name | Yes | - | Query name (max 253 chars) |
type | No | A | Query type (numeric or string: A, AAAA, MX, TXT, etc.) |
cd | No | false | Copied to the Checking Disabled flag; the authoritative service does not perform recursive validation |
do | No | false | Accepted for compatibility and currently ignored |
edns_client_subnet | No | - | Echoed in JSON and not used for resolution |
ct | No | - | Content-type hint (ignored, always returns JSON) |
random_padding | No | - | Accepted and ignored |
Supported Query Types:
| String | Numeric | Description |
|---|---|---|
| A | 1 | IPv4 address |
| AAAA | 28 | IPv6 address |
| CNAME | 5 | Canonical name |
| MX | 15 | Mail exchange |
| NS | 2 | Nameserver |
| TXT | 16 | Text record |
| SOA | 6 | Start of authority |
| PTR | 12 | Pointer record |
| SRV | 33 | Service record |
| CAA | 257 | Certificate authority |
| HTTPS | 65 | HTTPS service binding |
| SVCB | 64 | Service binding |
| NAPTR | 35 | Naming authority pointer |
| DS | 43 | Delegation signer |
| DNSKEY | 48 | DNSSEC key |
| RRSIG | 46 | DNSSEC signature |
| NSEC | 47 | Next secure |
| ANY | 255 | Any 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:
| Field | Type | Description |
|---|---|---|
Status | number | DNS RCODE (0=NOERROR, 2=SERVFAIL, 3=NXDOMAIN) |
TC | boolean | Truncated flag |
RD | boolean | Recursion Desired |
RA | boolean | Recursion Available (always false) |
AD | boolean | Authenticated Data (DNSSEC) |
CD | boolean | Checking Disabled |
Question | array | Question section |
Answer | array | Answer records |
Authority | array | Authority records |
Additional | array | Additional records (excluding OPT) |
edns_client_subnet | string | Echoed ECS if provided |
Comment | string | Optional 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
| Feature | Implementation |
|---|---|
| Size limits | 4KB max query, 8KB max base64 parameter |
| Client IP | The 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. |
| CORS | Any origin on the public query API |
| Cache-Control | Wire answers use DNS TTL; JSON is always no-store |
| Content-Type | application/dns-message (wire) or application/dns-json (JSON API, both /resolve and /dns-query?name=) |
Implementation Status
| Feature | Status |
|---|---|
| RFC 8484 GET | Implemented |
| RFC 8484 POST | Implemented |
JSON API /resolve | Implemented |
| Public CORS | Implemented |
| Verified proxy-chain client IP | Implemented |
| Size validation | Implemented |
| Cache-Control headers | Implemented |
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) - IPv6: Replace colons with hyphens (
2001:4860:4860::8888→ip-2001-4860-4860--8888).
Shared answering preserves the same 300-second DNS TTL, ordinary flat fields,
requested DNSSEC, and HTTP cache rules as the location services above. A valid
provider result with no geographic/network fields is NODATA; provider failure is
SERVFAIL. Missing/invalid IPs (including self/me) are FORMERR. Explicit private
addresses keep the local Private Network result without an external lookup.
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 LLCExamples
# 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 +shortPrivacy 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| Component | Purpose |
|---|---|
tenant_id | Binds session to authenticated user |
resource_path | Binds to specific watched resource |
created_timestamp | Enables expiration check |
client_ip_hash | Optional IP binding for added security |
Connection Security
Handshake Requirements:
| Step | Requirement |
|---|---|
| 1 | Client connects with Origin header matching allowed origins |
| 2 | Server validates session token (MUST be < 5 minutes old) |
| 3 | Server validates client IP matches token creation IP (optional) |
| 4 | Server sends initial resource state |
| 5 | Bidirectional communication established |
Rate Limiting:
- Maximum 10 WebSocket connections per tenant per minute
- Maximum 100 concurrent connections per tenant
Session Timeouts:
| Timeout | Duration | Action |
|---|---|---|
| Idle | 30 minutes | Disconnect with close code 1000 |
| Maximum | 24 hours | Force reconnection with new token |
| Token validity | 5 minutes | Reject if token older |
Reconnection Protocol
On disconnect, clients MUST:
- Obtain new session token via fresh
watchDNS query - Connect with new token (old tokens are single-use)
- 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 Code | Meaning |
|---|---|
| 4400 | Invalid session token |
| 4401 | Session token already used |
| 4403 | Access denied to resource |
| 4429 | Rate 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
| Parameter | Format | Description |
|---|---|---|
limit-N | limit-50 | Maximum results per page (1-1000, default 100) |
offset-N | offset-200 | Skip N results (for simple pagination) |
cursor-TOKEN | cursor-abc123 | Opaque cursor for next page |
Response Fields
{
"items": [...],
"cursor": "eyJsYXN0X2lkIjoiMTIzIn0",
"hasMore": true,
"total": 523
}| Field | Description |
|---|---|
items | Array of results for current page |
cursor | Opaque token for next page (Base64-encoded, URL-safe, HMAC-signed) |
hasMore | Boolean indicating more results exist |
total | Total 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 truncatedValidation Requirements:
Servers MUST:
- Verify HMAC signature before using cursor
- Reject cursors older than 1 hour (prevents stale enumeration)
- Verify
tenantmatches authenticated user (if applicable) - Verify
query_hashmatches current query parameters (prevents cross-query cursor reuse)
Attack Prevention:
| Attack | Mitigation |
|---|---|
| Cursor tampering | HMAC signature verification |
| Cross-user cursor theft | Tenant binding in cursor data |
| Cross-query cursor reuse | Query hash binding |
| Stale cursor enumeration | 1-hour expiration |
Error Response:
Invalid cursors return:
v=rdb1;s=invalid;err=E017;d=Invalid or expired cursor
| Code | Status | Description |
|---|---|---|
E017 | invalid | Cursor validation failed |
Privacy Considerations
For privacy-sensitive namespaces:
totalfield 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.
At the worst-case 49-byte e=b64;f=json envelope (including a ten-digit TTL),
2,586 source bytes become 3,448 Base64 bytes: 3,497 rendered bytes. One extra
source byte requires another four-byte Base64 quantum and would total 3,501;
Rails rejects it. TXT length octets and DNS headers are additional wire overhead;
the result still fits one TXT RR, with multiple character-strings as needed. 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):
| Mitigation | Requirement | Implementation |
|---|---|---|
| Listener admission limiting | REQUIRED | 10 NULL queries/second per source prefix and transport channel; staged in observe mode before enforcement |
| TCP Fallback | REQUIRED | Responses >4KB MUST use TC bit, require TCP |
| Authentication | REQUIRED | Hosted NULL records require an auth-rdbq... query token; Rails API keys are not DNS credentials |
| Source Validation | RECOMMENDED | BCP 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 TCPSize Limits by Transport:
| Transport | Max Response | Behavior |
|---|---|---|
| UDP | 4,096 bytes | TC bit set if exceeded |
| TCP | 65,536 bytes | Full response allowed |
| DoH | 65,536 bytes | Full response allowed |
Rate Limits for NULL Records:
| Scope | NULL queries/sec | Burst | Notes |
|---|---|---|---|
Source /24 IPv4 or /48 IPv6, per transport channel | 10 | 10 | Same 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 reassemblyChunk Integrity Verification
Clients MUST:
- Verify each chunk's SHA-256 hash matches
chunk_hashes[index]before storing - Verify reassembled content SHA-256 matches manifest
hash - Reject chunks with mismatched hashes (do not retry automatically - may indicate MITM)
- 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) │
└─────────────────────────────────────────────────────────────┘| Component | Size | Description |
|---|---|---|
| Nonce | 12 bytes | Unique per encryption (random or counter-based) |
| Ciphertext | Variable | Encrypted payload |
| Auth Tag | 16 bytes | GCM 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:
| Component | Format | Purpose |
|---|---|---|
| Query FQDN | UTF-8 bytes | Prevents cross-query key reuse |
| Client ephemeral pubkey | 32 bytes | Binds to specific client |
| Server ephemeral pubkey | 32 bytes | Binds to specific response |
| Timestamp | 8 bytes (big-endian Unix epoch) | Prevents replay |
| Nonce | 8 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, nonceEphemeral 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>
| Field | Format | Description |
|---|---|---|
k | Base64 (32 bytes decoded) | Server ephemeral X25519 public key |
d | Base64 | Nonce + 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 bytesDecryption:
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:
| TLD | Primary | Cross-Backup |
|---|---|---|
.com | ns1/ns2.resolvedb.com | ns-backup.resolvedb.net |
.net | ns1/ns2.resolvedb.net | ns-backup.resolvedb.org |
.org | ns1/ns2.resolvedb.org | ns-backup.resolvedb.io |
.io | ns1/ns2.resolvedb.io | ns-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
For an otherwise valid product query with an unsupported answer type, the negative NSEC bitmap retains TXT. Answer-type rejection does not establish absence of a TXT value: omitting TXT would let aggressive DNSSEC caching (RFC 8198) suppress a later supported read. Actual TXT absence still produces a proof without TXT, subject to the request's negative-cache lifetime.
Zone/key serving generations (implemented prefactor)
The serving lifecycle builds a complete static zone, published DNSKEY set, dynamic-response signer, actual SOA serial, and static-signature validity window before publishing a replacement. Native requests pin that immutable generation through all response construction, including later NS, SOA, and NSEC lookups. An in-flight request can finish on an earlier complete generation. Product data and authorization state retain their separate lifecycles; this is not a global data snapshot.
Persistent-key refresh retains its half-validity schedule (15 days for the default 30-day signatures) and hourly retry after failure. All required static signatures are verified before publication; failed reads, signing, or validation retain the prior generation and its original validity window. The expiry gauge reads the earliest actual published RRSIG expiration, including after failure. Ephemeral-key configurations retain their existing no-refresh behavior.
Initialization now precedes both serving modes, including DNS_ENABLED=false.
Configure DNS_ZONE, NS01_IP, NS02_IP, NS03_IP, and the existing DNSSEC key
settings for HTTP-only processes as well. Native DNS and HTTP state share the
same owner. Hosted, public lookup, dataset/temporal, schema, and static zone responses use this generation for
requested signatures/proofs and DNSKEY serving, including HTTP-only operation.
Unusable required signing material fails those responses closed. This is
implementation status, not evidence of a production deployment.
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.
| Parameter | Value | Rationale |
|---|---|---|
| Hash Algorithm | SHA-1 (1) | Required by RFC 5155 |
| Iterations | 0-10 | Per RFC 9276 guidance (low for online signing) |
| Salt Length | 0-8 bytes | Random salt, rotate with ZSK |
| Opt-Out | Disabled | All 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:
| Context | Max Tolerance | Rationale |
|---|---|---|
| Authenticated requests | 5 seconds | Limits replay window |
| Unsigned public queries | 30 seconds | Allows for clock skew |
| Encrypted responses | 5 seconds | Bound 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
| Field | Format | Requirements |
|---|---|---|
ts- | Unix timestamp | Within 5 seconds of server time |
nonce- | 8 alphanumeric chars | Cryptographically random, unique per request |
Server-Side Tracking:
Servers MUST:
- Reject requests with
tsmore than 5 seconds from server time - Track
(nonce, ts)pairs for 10 seconds (2x tolerance window) - Reject duplicate
(nonce, ts)pairs withsecviolstatus - Use constant-time comparison for nonce matching
New Error Code:
| Code | Status | Description | Retryable | Recovery |
|---|---|---|---|---|
E016 | secviol | Replay attack detected | No | Generate 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 transportSymmetric (Shared Secret):
- AES-256-GCM encryption
- Pre-shared keys (out-of-band)
- Argon2id key derivation
- AEADAsymmetric (Public Key):
- X25519 key exchange
- ChaCha20-Poly1305 encryption
- Ephemeral keys (PFS)
- Public keys in TLSA recordsLayer 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.netAlgorithm Requirements (CRITICAL)
Allowed Algorithms:
| Algorithm | Use Case | Status |
|---|---|---|
EdDSA (Ed25519) | Primary signing algorithm | REQUIRED |
ES256 | ECDSA P-256 (legacy compatibility) | ALLOWED |
RS256 | RSA 2048+ (legacy compatibility) | ALLOWED |
Forbidden Algorithms:
| Algorithm | Reason | Action |
|---|---|---|
none | No signature | MUST reject with secviol |
HS256, HS384, HS512 | Symmetric key confusion risk | MUST reject |
PS256, PS384, PS512 | Implementation complexity | SHOULD reject |
Algorithm Confusion Prevention:
Implementations MUST:
- Explicit allowlist: Only process tokens with algorithms from the allowed list above
- Pre-parse validation: Check
algheader BEFORE any signature verification - Reject before decode: If
algis forbidden, reject immediately without attempting verification - Case-sensitive matching:
"alg": "None"and"alg": "NONE"MUST also be rejected - 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:
| Claim | Type | Description |
|---|---|---|
sub | string | Subject (user ID or service ID) |
iss | string | Issuer (resolvedb.io or tenant issuer) |
aud | string | Audience (must include resolvedb.io) |
exp | integer | Expiration time (Unix timestamp) |
iat | integer | Issued at (Unix timestamp) |
nbf | integer | Not before (Unix timestamp) |
jti | string | JWT ID (unique token identifier for revocation) |
tenant | string | Namespace/tenant identifier |
scopes | array | Permission scopes (e.g., ["read", "write"]) |
Optional Claims:
| Claim | Type | Description |
|---|---|---|
rate_limit_tier | string | Override tier: free, pro, enterprise |
metadata | object | Arbitrary key-value metadata |
nonce | string | Replay 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:
-
Token Hash Reference (REQUIRED): Store token server-side, reference by cryptographic hash
get.auth-h-<32-hex-chars>.resource.namespace.v1.resolvedb.netSecurity 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
expclaim - References MUST be invalidated when corresponding token is revoked
- Server SHOULD rate-limit
auth-h-queries to prevent enumeration attacks
-
Short-Lived Tokens: Use compact tokens with minimal claims (max 5 minutes validity)
-
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
- UQRP parameter-label chars: ASCII
a-z,A-Z,0-9,-,_, including edge hyphens for negative coordinates and hosted keys. Reserved encoding and credential prefixes obey the stricter grammar under Parameter Encoding. - Structural LDH labels follow hostname syntax; these preferred hostname rules do not exclude UQRP's broader parameter-label grammar from DNS wire labels.
Important: Literal colons (:) are outside the UQRP label grammar. Encode
such parameter bytes with Base32 or hex. Prefixes use hyphens (b32-, not b32:).
Case Normalization (CRITICAL)
Per RFC 1035 Section 2.3.3, DNS names are case-insensitive. Implementations MUST normalize consistently to prevent security issues.
The shared answerer removes an optional terminal root dot and normalizes DNS letter case consistently on all transports. Only the DNS spelling is normalized: decoded parameter bytes and TXT payload contents retain their original case. Query Base64 encodings are retired because they cannot preserve byte meaning under DNS case equivalence; use the case-stable parameter forms above.
Normalization Requirements:
| Component | Normalization Point | Rule |
|---|---|---|
| Query FQDN | Immediately at parse | Lowercase before ANY processing |
| Namespace | Immediately at extraction | Lowercase before authorization check |
| Cache key | After normalization | Use normalized form only |
| Auth comparison | All comparisons | Case-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() # FIRSTImplementation Pattern:
// CORRECT: Normalize immediately at parse
fn parse_query(qname: &str) -> Result<ParsedQuery> {
let normalized = qname.strip_suffix('.').unwrap_or(qname).to_ascii_lowercase();
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
- Hash Reference: Store full data, query by hash
- Multi-Query: Split across queries
- Compression: Dictionary for common patterns
- 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:
| Mode | Behavior |
|---|---|
off | No bucket allocation or decisions; code default |
observe | Exercise the production buckets and emit bounded telemetry without changing responses |
enforce | Apply 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
| Bucket | Sustained QPS | Burst |
|---|---|---|
| UDP standard | 1,000 | 100 |
| Reliable standard | 1,000 | 100 |
| NULL, either channel | 10 | 10 |
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) -> HealthStatusService 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.netWhy Explicit Location?
| Implicit (WRONG) | Explicit (CORRECT) |
|---|---|
| Server infers from source IP | Client provides location |
| Breaks through VPNs/proxies | Works everywhere |
| Different target from different networks | Same explicit target; time-varying provider data may still change |
| Privacy leak | Privacy preserved |
| Cache fragmentation (ECS scopes) | Fully cacheable |
Note: Use case-stable Base32 or hex for parameter bytes that are not safe DNS label characters, such as colons. Operation-specific decoding and Go/JS encoder support are implemented; direct coordinate/IP/what3words forms remain supported.
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:
| Requirement | Implementation | RFC Reference |
|---|---|---|
| Scope Prefix Handling | REQUIRED | RFC 7871 Section 7.3 |
| Privacy Mode Support | REQUIRED | RFC 7871 Section 12.3 |
| Opt-Out Mechanism | REQUIRED | Client 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 clientsPrivacy 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=0GeoIP 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 responseTTL 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:
| Class | TTL | Use Case |
|---|---|---|
immutable | 604800 (7 days) | Conceptual settled/immutable policy |
stable | 86400 (24 hours) | Conceptual long-lived policy |
standard | 3600 (1 hour) | General default policy |
dynamic | 300 (5 min) | Frequently changing public data policy |
volatile | 30-60 sec | Rapidly changing public data policy |
nocache | 0 | Authenticated private answers |
Operation TTL Defaults
| Shipped query | Default TTL | Notes |
|---|---|---|
Public get / info | 3600 | Successful private get answers override this to 0 |
geoip / weather | 300 | Explicit target only |
| forecast | 1800 | Provider-backed |
| stock / forex | 60 while active, 3600 while closed | Provider and market-state dependent |
| crypto / BTC | 60 | Continuously changing |
| units | 86400 | Deterministic conversion |
| sun / moon | 3600 | Time-dependent |
| temporal dataset identity | 3600 or 604800 | Long 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:
| Case | TTL Behavior |
|---|---|
Authenticated (auth-rdbq prefix) | TTL 0 - private answers are excluded from shared caches |
| Unknown data | NODATA; 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 Method | Coverage | Fallback |
|---|---|---|
| Full query parsing | Primary - extracts auth_token from parsed query | Required |
| String matching | Secondary - checks for .auth- in QNAME | Backup 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:
- Leading credentials require structural detection; encoded parameter bytes
never become credentials (including decoded
auth=...orauth-...text). - Malformed queries may cache and serve to unauthorized users
- Cross-user cache poisoning becomes possible
Negative Caching (RFC 2308)
The production authoritative zone intentionally returns NODATA rather than NXDOMAIN for unknown UQRP names:
| Response | RCODE | Meaning | TTL Source |
|---|---|---|---|
| NODATA | 0 (empty answer) | Name or requested type has no data | SOA 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
| Feature | Status | Notes |
|---|---|---|
| TTL in response format | Implemented | ttl=<seconds> is TXT metadata; the DNS RR carries the effective resolver TTL |
| SOA MINIMUM for negative cache | Implemented | 3600s default |
| Cache respects response TTL | Implemented | Extracts from first answer, clamps to ttl_min/ttl_max |
| Per-operation TTL defaults | Implemented | ttl_for_operation() and determine_answer_ttl() in protocol/constants.rs |
| TTL class constants | Implemented | TTL_IMMUTABLE (7d), TTL_STABLE (24h), TTL_STANDARD (1h), TTL_DYNAMIC (5m), TTL_VOLATILE_MIN/MAX (30-60s) |
| Error caching | Implemented | Failures use DNS RCODE/NODATA behavior; transient failures do not emit a cacheable positive answer |
| Auth query cache exclusion | Implemented | Successful private answers use TTL 0 and authenticated qnames are excluded from caches |
| Rate-limit caching | Implemented | DoH 429 is no-store; native UDP slip and TCP/DoT REFUSED do not emit a positive UQRP answer |
Protocol Evolution
Resource Selection and Envelope Formats [IMPLEMENTED]
Resource selection distinguishes resource versions from envelope formats and
dataset release versions. Resource versions select exact resource contracts or
hosted keys; they do not negotiate the envelope format. A request for v2 must
not silently select v1, which may name different customer data or a different
resource contract. Unsupported public resource versions are policy denials.
The former draft's automatic protocol-downgrade and redirect procedure is superseded by this distinction. No health-record negotiation or automatic resource-version fallback is part of the implemented contract. Future envelope-format changes must be specified explicitly and coordinated with consumers; changing a qname's resource version is not a substitute for defining a new envelope format.
Domain Portfolio
| Domain | Usage | Status |
|---|---|---|
| resolvedb.net | Authoritative UQRP DNS zone | Live |
| resolvedb.io | Customer web/docs and DoH (doh.resolvedb.io) | Live |
| resolvedb.com | Rails API (api.resolvedb.com) | Live |
| resolvedb.app | App endpoints | Reserved |
| resolvedb.dev | Developer portal | Reserved |
| resolvedb.org | Documentation | Reserved |
| resolvedb.cloud | CDN endpoints | Reserved |
| resolvedb.tech | Technical demos | Reserved |
| resolvedb.ca | Canadian presence | Reserved |
Revision History
This specification is under active development. A public revision history will be published with the standalone protocol specification.