AcceptedDecided
ADR 0010: Per-RRset optimistic concurrency for the editor
The RRset editor at /zones/[zoneId] allows simultaneous edits from multiple operators across multiple browser tabs.
On this page
- Status: Accepted
- Date: 2026-05-17
- Deciders: @jseifeddine
- Update (2026-05-26): implemented - the RRset route
(
app/api/admin/pdns/zones/[zoneId]/rrsets/route.ts) compares the client'sIf-Matchagainst the per-RRset hash fromlib/pdns/rrset-hash.tsand answers 412 on a mismatch.
Context#
The RRset editor at /zones/[zoneId] allows simultaneous edits from multiple operators across
multiple browser tabs. Without a concurrency guard, the last writer silently overwrites every
prior edit; the audit log records the sequence but the dropped content is gone from the live zone.
An earlier iteration shipped a zone-level optimistic check keyed on PDNS's edited_serial:
the client sent the zone serial it had loaded; the PATCH route compared it to the live PDNS
serial and returned 409 on mismatch. This was removed for the reason captured in
app/api/admin/pdns/zones/[zoneId]/rrsets/route.ts:
the wrong granularity (every successful edit advances the zone's serial so consecutive edits in the same session falsely 409'd against the user's own prior write, since router.refresh() is async and the page's snapshot is stale until it propagates).
That left the editor as last-write-wins, with the audit log as the only reconciliation path. This ADR replaces the discarded approach with per-RRset hashing.
Decision#
Add per-RRset optimistic locking, keyed on a structural hash of each RRset's content as the
client saw it. The PATCH route accepts an optional expected: { hash: string } field per
change pair; the server computes the same hash from the live PDNS rrset and returns 409 only
when THIS rrset was modified by someone else between load and save.
Hash shape#
Deterministic over the RRset's structural content:
function rrsetHash(rrset: {
name: string;
type: string;
ttl: number;
records: Array<{ content: string; disabled?: boolean }>;
}): string {
// Canonicalize before hashing so equivalent rrsets produce the same hash:
// - sort records by (content, disabled) lexicographically;
// - normalize disabled to a concrete boolean.
const canonical = JSON.stringify({
name: rrset.name.toLowerCase(),
type: rrset.type.toUpperCase(),
ttl: rrset.ttl,
records: [...rrset.records]
.map((r) => ({ content: r.content, disabled: r.disabled === true }))
.sort((a, b) => a.content.localeCompare(b.content)),
});
return crypto.createHash("sha256").update(canonical).digest("hex").slice(0, 16);
}Truncated to 16 hex characters (64 bits) - enough collision resistance for an RRset-by-RRset check (operator concurrency is on the order of dozens of users, not adversaries).
Wire format#
Client sends in the existing PATCH body:
{
changes: [
{
kind: "update" | "create" | "delete",
before: RRset | null,
after: RRset | null,
expected?: { hash: string }, // NEW: hash of `before` as the client saw it
},
],
}Server:
- For each change where
expectedis set ANDbeforeis non-null:- Fetch the live rrset from PDNS (already done in the audit
beforecapture path). - Compute its hash.
- Compare against
expected.hash.
- Fetch the live rrset from PDNS (already done in the audit
- On mismatch, return 409 with a structured body:
{
error: "conflict",
conflicts: [
{
rrsetName: string,
rrsetType: string,
reason: "modified" | "deleted", // deleted = live rrset is gone
currentHash?: string, // present when reason="modified"
},
],
}- On any conflict, the route applies none of the changes (transactional all-or-nothing). Mixed apply would leave the zone in a partially-edited state the operator didn't intend.
Client UX#
On 409:
- Toast the conflict count + summary ("3 records changed under you").
- Render an inline reconciliation panel showing per-conflict diffs (live vs. your change).
- Operator can either reload (discard their edits) or override (re-submit without
expected). Override is audited withrecord.overrideaction so post-hoc reconciliation has the trail.
Create + Delete paths#
- Create (
before: null): noexpectedmakes sense - there's nothing to hash. If two operators both create the same (name, type), the second is treated as an update; the server-computed hash diff surfaces in the per-RRset audit row for review. - Delete (
after: null): theexpected.hashofbeforeis still meaningful - the operator is asserting "I want to delete this rrset as I last saw it." If someone changed it in between, the conflict path fires and the operator decides whether to delete the new content.
Server-side caching to avoid the extra round-trip#
The audit before snapshot path (line ~180 of rrsets/route.ts) already fetches the live
rrset before applying changes. Reuse the same fetch for hash comparison - no additional
HTTP round-trip to PDNS.
Rationale#
- Per-RRset granularity matches the editor's mental model. Operators edit one rrset at a time; collisions across distinct rrsets shouldn't block each other.
- Audit log already carries
beforesnapshots - the conflict reconciliation panel can reuse the redacted snapshot format fromlib/audit/log.ts. - Hash, not version number. A version column on rrsets would require schema changes that PDNS doesn't expose (we don't own the rrset table). Hashing is server-side, requires zero PDNS schema cooperation, and is robust to in-place edits made by other tools.
- Optional
expectedpreserves backward compatibility: old clients that don't send it fall back to last-write-wins, same as today. - Override path is audited so an operator who blew through a 409 is on the audit log; this is the security property that lets last-write-wins coexist with the optimistic check.
Alternatives considered#
- Zone-level
edited_serialcheck. The discarded earlier approach (consecutive own-edits false-conflict). The per-RRset hash fixes that without reintroducing the false-positive. - ETag / If-Match HTTP semantics. Conceptually equivalent to the hash field but requires per-rrset GET endpoints with their own ETags - adds API surface for marginal gain.
- PDNS native concurrency. PDNS's HTTP API has no concurrency primitive (no ETag, no If-Match, no row versioning). Per-RRset locking has to live in the app layer.
- Pessimistic locking (per-rrset edit lock). Operator A starts editing → lock; B can't edit until A times out or commits. Wrong UX for an admin tool - A might walk away with the lock open. Pessimistic locks are for shared-document editors with explicit "leave session" signals.
Consequences#
- Wire format change (additive
expectedfield) - backward-compatible. - One new pure helper in
lib/pdns/rrsets.tsor a newlib/pdns/rrset-hash.ts- easy to unit-test (pure function over a record). - New conflict-response shape - typed in
lib/pdns/types.tsso client + server agree. - Audit action vocabulary gains
record.override(or similar) for the "operator acknowledged conflict and re-submitted withoutexpected" path. - No PDNS API change. No new schema column. No migration.
- Integration test surface: 4 scenarios - happy path (no conflict, applies), single-
rrset conflict (returns 409, nothing applied), multi-change with one conflict (still
nothing applied), override path (re-submit without
expectedapplies).
Implementation order#
- Add the pure
rrsetHash(rrset)helper + unit tests (T+1). - Add
expected?: { hash: string }to the PATCH input Zod schema (T+1). - Implement the server-side compare + conflict-response shape; gate on
expectedpresence (T+2). - Audit
record.overridefor the no-expectedresubmit path (T+2). - Integration tests (T+3).
- Client: extend the editor's submit to include
expectedfrom the loaded rrset; render the conflict reconciliation panel (T+4 / T+5).
Stop after step 3 if shipping in two phases - server-side is useful to any future client even before the editor learns to send the field.