AcceptedDecided
ADR 0014: Per-zone authority + observed daemon capabilities (retire per-server role)
We modeled each backend with a single role (primary | secondary) plus an optional primaryId pin linking a secondary to exactly one primary.
On this page
- Status: Accepted
- Date: 2026-05-23
- Deciders: @jseifeddine
Context#
We modeled each backend with a single role (primary | secondary) plus an optional primaryId
pin linking a secondary to exactly one primary. Multi-primary "clusters" are a separate grouping
with an API-routing picker (ADR-0013, cluster-picker.choosePeer).
PowerDNS has no server-level role. Two real things exist:
- Per-zone authority. Each zone has a
kind-Native | Master/Primary | Slave/Secondary | Producer | Consumer- and a slave carriesmasters[]. Authority is decided per zone, not per daemon. - Per-daemon capability. Behaviour is governed by config flags readable from the read-only
/configendpoint:primary/master,secondary/slave,autosecondary/superslave,launch(which backends),g*-dnssec,api.
A single daemon legitimately runs primary=yes secondary=yes and is Master for some zones, Slave
for others, and Native for others at the same time. Our role is an app-level fiction that
mis-models every mixed / mesh / multi-primary deployment. Symptoms already seen: a backend
reconfigured as a secondary surfaced "Orphan secondaries" and an empty zone list; read-only
behaviour had to be moved off role onto zone kind (done); the /config advisory still keys off
role. role is the remaining fiction.
Decision#
Retire
roleandprimaryId. A backend is a connection - identity, base URL, serverId, api-key, default/enabled flags - with no declared authority.Observe, don't declare. Persist a capability snapshot per backend, refreshed on the existing poll from
/config+/servers/{id}:api,primary,secondary,autosecondary,launchbackends, per-backend DNSSEC, daemon + API version. Same write path and cache pattern as the version cache - a sanctionedlib/pdns → lib/dbbridge (ADR-0013).Amendment (2026-07-22, #111): "observe, don't declare" holds for everything PowerDNS can actually tell us - and there is exactly one thing it can't. A daemon whose database user is read-only (the public half of a native-zone deployment replicated at the DB layer) reports
primary=no, secondary=noover/config, byte-identical to a standalone primary. There is no flag, no probe, and no safe write-test that distinguishes them, so derivation necessarily calls it writable andchoosePeerrotates writes onto a node that rejects them.We therefore add ONE operator-declared field,
pdns_servers.write_mode(auto|read_only), and constrain it tightly so it can't grow back into therolewe deleted:- It can only ever subtract.
read_onlyvetoes a daemon the capabilities called writable;autonever promotes an observed mirror into a write target. Authority still flows one way. - It governs write routing and the operator-facing backend pickers only - the predicate is
isServerWriteTarget(). AXFR topology derivation (decision 4) keeps reading raw capabilities, becausemasters[]resolution is a fact about the DNS protocol that an operator's routing preference must not rewrite. Marking a genuine AXFR primaryread_onlystops writes going to it; it must not make the secondaries pulling from it un-resolvable. - It defaults to
auto, so every existing backend behaves exactly as before.
This is a deliberate, bounded exception. The test for adding another one: PowerDNS must be incapable of reporting the fact, not merely inconvenient to ask.
Amendment (2026-08-08, #122): the snapshot also carries
enable-lua-records(luaRecords: "no" | "yes" | "shared"). It is an observed/configflag like the rest, and it belongs here for the same reason DNSSEC does: it changes what the app will let an operator create. LUA records are executable server-side code, so "is Lua armed on this daemon?" is a capability question, and computing it live on every zone render (which is what the record editor did before) both hid it from every capability surface and put two PDNS calls on the zone page's critical path. The field is optional - absent means "snapshot predates this field", which readers must treat as not observed (fall back to a live read) rather than asno. Zone-levelENABLE-LUA-RECORDSmetadata still ORs on top; a daemon-levelnois not a veto.- It can only ever subtract.
Gate editability on zone
kindvia one ops resolver:zoneCapabilities(kind) → { rrsets, metadata, masters, dnssec, axfrRetrieve, delete }. Generalizes the existingisReadOnlyZoneKindand encodes the real writable options on a Slave (masters, metadata, retrieve-now, delete) instead of treating it as a flat read-only.Derive replication topology from truth. A slave's
masters[]are DNS-layer AXFR addresses, not API URLs. Add a per-backend advertised DNS address set - default-suggested from the API URL host, overridable, multi-valued. Draw an edge when amasters[]entry matches a backend's advertised address; unmatched masters render as external/unmanaged nodes, never a false "orphan." Matching is on AXFR address, not NS membership, so hidden primaries work.One
backend groupreplaces theprimaryIdpin and subsumes the multi-primary cluster. A group is app-level grouping for API routing (choosePeeroperates within a group) and visual clustering - never an authority gate.Amendment (2026-05-23): rather than a new many-to-many
backend_grouptable, we reuse the existingpdns_clusterstable +cluster_idmembership (1:N) as the group. None of the supported topologies (standalone / primary+secondaries / multi-primary) need a backend in more than one group, so many-to-many added churn and risk for no benefit. We drop onlyprimary_id; a primary's secondaries are now the secondary-capable members of its group.choosePeerfilters group members to write-capable ones so a secondary in a group is never a write target.Expose a single
BackendProfile(identity, reachability, capabilities, zone inventory by kind, advisories) that dashboard / server-detail / zone-detail / topology all consume. No feature re-derives role logic.
Rationale#
Model the two things PowerDNS actually has - per-zone kind, per-daemon capability - and derive everything else. Operators declare nothing PDNS can tell us, and the app can't drift from reality because it re-reads reality each poll. Honest trade-offs: more moving parts (a capability snapshot, a group table, and an advertised-address set) and a non-trivial dual-dialect migration; topology matching is best-effort and degrades to "external node" whenever the API host ≠ the DNS address (common in real deployments) - incomplete, but never wrong.
Alternatives considered#
- Keep
role, layer capabilities as advisory only. Leaves the flaw; mixed-mode still mis-modeled. - Demote
roleto a non-authoritative hint. Half-measure with two sources of truth; rejected by decision. - Auto-derive the match address from NS records, or require API URL == NS name. Hidden primaries aren't in the NS set, and the API URL ≠ the DNS IP in real deployments. An explicit, URL-seeded, overridable address set is the only honest match key.
Consequences#
pdns_servers: droprole,primaryId; addcapabilities(+fetchedAt) andadvertisedAddresses. Dual-dialect migration (PG + SQLite), run at boot (ADR-0011).- Existing clusters are preserved (we reuse
pdns_clusters+cluster_id, per the amendment, so multi-primary groups carry over untouched). primary_idpins are NOT backfilled. Under "derive from truth," a primary→secondary edge comes from each mirror zone'smasters[], not from a stored pin - so the migration simply drops the column and the edge re-derives on the first poll. The only case this loses is a pinned secondary whose API host ≠ its DNS address AND that isn't in a cluster; it degrades to a standalone secondary (visible + editable), never a false "orphan," and the operator can set its advertised address or group it. Backfilling would mean synthesizing cluster rows (with generated UUIDs/slugs) in cross-dialect SQL for a relationship the model no longer needs - churn for no benefit, so we don't.
- Existing clusters are preserved (we reuse
- The
/configadvisory keys off capabilities-vs-inventory, notrole- feeding ADR-0015's bell. - "Orphan secondary" UI and role pickers are removed; server forms gain an advertised-address field with a URL-seeded default; zone editability comes from the kind ops resolver.
cluster-picker/syncoperate on group membership - aligns with ADR-0013's "relocate abovelib/pdns" future work.
References#
- ADR-0013 (PDNS DB bridge,
cluster-picker), ADR-0010 (per-RRset concurrency), ADR-0004 (three-layer architecture) - CLAUDE.md (standalone / primary+secondaries / multi-primary topologies)
- Follow-up: ADR-0015 (backend health advisories)