Ctrl K
ComunidadGuías…

docs/adr/0059-dns-circuit-breaker-and-strictness.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# ADR-0059: DNS Consensus Strictness and Per-Endpoint Circuit Breakers

## Metadata

| Field          | Value                          |
| -------------- | ------------------------------ |
| **Status**     | Superseded by [ADR-0072](0072-dns-consensus-revalidation-hardening.md) and [ADR-0075](0075-dns-unbound-single-source-completion.md)                       |
| **Date**       | 2026-08-16                     |
| **Authors**    | AlessioBrillo                  |
| **Deciders**   | AlessioBrillo                  |
| **Supersedes** | N/A                            |
| **Relates to** | ADR-0002, ADR-0039, ADR-0040, ADR-0044, ADR-0045, ADR-0047, ADR-0048, ADR-0051 |
| **Project**    | DOMINUS                        |

## Context

The DNS 2-of-3 consensus gate (ADRs 0039, 0040, 0045) is fail-closed: an
Available verdict needs confirmation from a second, resolver-disjoint leg.
Four hardening gaps remain:

1. **Strictness drift on `DNS_CONSENSUS_REQUIRED_AVAILABLE=2`.** The
   documented semantics (src/config.ts) say 2 = "BOTH the secondary and the
   tertiary must confirm the primary's Available". The implementation,
   however, still treats the tertiary as a *rescue* leg: when the secondary
   fails, a tertiary-only Available passes the gate. Under the strict
   configuration this is a single independent confirmation dressed as two —
   exactly the false-confidence the gate exists to prevent. The default (1)
   is unaffected.
2. **No circuit breaker on the DNS layer.** RDAP and WHOIS have two-level
   circuit breakers (global + per-server, ADR-0051); DNS has only
   per-connection strike accounting inside each DoT pool. A persistently
   failing resolver (egress port 853 filtered, DoH endpoint down, private
   recursor wedged) burns the full lookup timeout on every query, every run,
   for the whole window — and pollutes the availability majority with
   undecided legs.
3. **Parking-IP detection is a silent no-op by default.** `ParkingIpRegistry`
   is only populated when `DNS_PARKING_IPS_PATH` points at a file; with no
   file the check (when enabled) can never fire. There is no bundled
   reference list, so the feature's default behaviour is "off" without saying
   so.
4. **Watchlist DNS verdicts are single-provider by design.** The watchlist
   polls through the primary provider only, with an RDAP confirmation as the
   second opinion. The RDAP leg (authoritative, ADR-0035) is the actual
   safety net, but the design is invisible: a reader of the logs cannot tell
   that an Available was not consensus-verified.

Also surfaced: two dead config keys (`SCORING_CONFIDENCE_THRESHOLD`,
`SCORING_RECOMMEND_THRESHOLD`) that the scoring engine no longer reads, and
doc drift in ADR-0002 (500 EUR cap / 0.3 threshold) versus the
evidence-anchored value design (ADR-0055). These are cleaned up as
documentation/config hygiene in the same changeset.

## Decision Drivers

1. **Strict configuration must be strict** — when the operator pins
   `DNS_CONSENSUS_REQUIRED_AVAILABLE=2`, a tertiary-only Available is an
   unverifiable verdict, not a rescued one. The default (1) keeps today's
   rescue semantics.
2. **Fail-safe breaker bookkeeping** — the breaker is a resilience
   optimization, not a safety gate: the fail-closed Unknown path is the
   safety. Breaker bookkeeping failures (Redis down, registry misconfig)
   must never take DNS down; they degrade to "allow".
3. **Zero-config correctness** — parking detection must work out of the box
   with the bundled reference list, matching the provider-abstraction and
   cost-discipline principles (ADR-0001, ADR-0004).
4. **Observability at zero new cost** — breaker state joins the existing
   Prometheus surface (`dominus_dns_breaker_*`).
5. **Cancellation discipline (ADR-0058)** — caller-initiated aborts are not
   server failures and must not trip the breaker.

## Considered Options

### Option A: Strict requiredAvailable=2 + per-endpoint breaker registry + bundled parking list (chosen)

**Strictness:** in the consensus gate, when `requiredAvailable >= 2` and the
secondary failed, a tertiary Available no longer passes the candidate: the
verdict is Unknown (unverifiable), counted in `unverifiable`. The tertiary is
still consulted so a tertiary *Registered* keeps its veto power — the rescue
leg is never a rubber stamp, and the conservative direction stays intact.

**Breaker:** a new `DnsBreakerRegistry` in
`src/providers/dns/dns-breaker.ts` keyed per endpoint:
`doh:<host>`, `dot:<endpoint|servername|port>`, `native:<nameservers join>`,
`native:system-resolver`. It reuses the existing `CircuitBreaker` policy
(in-memory, default 5 failures / 60 s window / 120 s cooldown — mirroring the
RDAP per-server breaker) or `DistributedCircuitBreaker` when Redis is
available (ADR-0033), keyed `cb:dns:<endpoint>`. The primary, secondary, and
tertiary providers share one registry instance per run composition.

Inside `#raceGroup` a leg whose breaker is open is skipped (resolved as
`undefined` without a wire query) and the availability majority denominator
still counts it — a skipped leg never manufactures an Available. Breaker
outcomes are recorded only after the caller's abort signal is checked:
`callerAborted` is captured *before* the internal `childAbort` fires, so
run-cancellation aborts never trip the breaker (ADR-0058). A single
resolving leg still wins (conservative-safe, unchanged). All registry calls
are guarded: bookkeeping errors fail open.

New config, default-on with the same shape as the RDAP breaker:
`DNS_CIRCUIT_BREAKER_ENABLED=true`, `DNS_CIRCUIT_BREAKER_FAILURE_THRESHOLD=5`,
`DNS_CIRCUIT_BREAKER_WINDOW_MS=60000`, `DNS_CIRCUIT_BREAKER_COOLDOWN_MS=120000`.

**Parking:** `ParkingIpRegistry.load(path, fallbackPath)` falls back to a
bundled `src/providers/dns/parking-ips.json` reference list when
`DNS_PARKING_IPS_PATH` is unset or the file is missing. The check itself
stays opt-in (`DNS_PARKING_CHECK_ENABLED=false` default) — but enabling it
now does something without extra setup.

**Watchlist:** the single-provider DNS verdict is labelled in the job log
(warn-level on an Available path) and documented: the mandatory RDAP
confirmation is the authoritative second opinion (ADR-0035), so watchlist
notifications remain two-leg checked — DNS fast path + RDAP authority.

**Advantages:**

- Strict config becomes strictly fail-closed; the tertiary is demoted to
  what it is: a veto/confirmation leg, not a substitute second opinion.
- A dead resolver stops consuming wire timeouts after `failureThreshold`
  failures; consensus degrades fast and visibly instead of slowly and
  silently.
- Parking detection works with zero config; the bundled list is a
  conservative, community-maintainable reference.
- Breaker state is observable (`dominus_dns_breaker_*`) and aborts stay
  free of spurious trips.

**Disadvantages:**

- One extra config surface to document; risk of mis-tuning (mitigated by
  sane defaults mirroring the RDAP breaker and `ENABLED=false` escape hatch).
- The bundled parking list is a static snapshot; operators who want fresher
  data still point `DNS_PARKING_IPS_PATH` at their own file.
- Strict mode widens `unverifiable` when the secondary is down — that is
  the point, but it changes the degraded-run tally in strict deployments.

**Cost Implications:** zero infrastructure cost (community edition, ADR-0001).
No paid APIs. Development effort: 3 focused changesets (config + registry,
provider wiring, stage strictness + parking default + watchlist label).

**Risk Assessment:** Low. Every change preserves the fail-closed direction;
the breaker can only *skip* queries that would otherwise time out; the
strictness change only tightens a configuration whose documentation already
promises the tightened semantics.

---

### Option B: Only fix the strictness drift

Ship the `requiredAvailable=2` fix and the parking default; leave DNS
without a circuit breaker.

**Advantages:**

- Minimal diff; no new config surface.
- The strictness bug is the only one with an availability-verdict impact.

**Disadvantages:**

- The resilience gap (C2) stays: a dead resolver keeps consuming the full
  timeout budget on every query and pollutes the majority tally with
  undecided legs — the exact failure mode the RDAP/WHOIS layers already
  protect against.
- No observability for resolver health beyond per-query timeouts.

---

### Option C: Per-leg strike counting only (no registry, no config)

Reuse the DoT pool's per-connection strikes as a crude open/skip heuristic
with no shared registry and no metrics.

**Advantages:**

- No new files or config.

**Disadvantages:**

- Only covers DoT pools; DoH and native legs stay unprotected.
- No shared state between primary/secondary/tertiary (each builds its own
  pools); no observability; semantics undocumented.

---

## Decision

**Option A.** The strictness fix is mandatory (documented semantics vs.
implementation drift). The breaker registry closes the resilience parity gap
with RDAP/WHOIS at zero infra cost, and the bundled parking list removes a
silent no-op default. The watchlist label is documentation + a log line, not
a behavioural change — the RDAP leg is already the authoritative second
opinion there.

## Consequences

### Positive

- `DNS_CONSENSUS_REQUIRED_AVAILABLE=2` now means what the config
  documentation says: both secondary and tertiary must confirm. Tertiary-only
  rescues land in `unverifiable` and the run degrades visibly.
- DNS failures become circuit-aware: after `failureThreshold` consecutive
  failures an endpoint is skipped for `cooldownMs`, the availability
  majority stays honest, and `dominus_dns_breaker_*` exposes open/half-open
  state to Prometheus.
- Parking-IP detection works out of the box (bundled reference list) while
  staying opt-in.
- Cancellation discipline is preserved: caller aborts never trip breakers.
- Dead config keys removed; ADR-0002 doc drift corrected (references
  ADR-0055).

### Negative

- Strict deployments see wider `unverifiable` tallies during secondary
  outages — intended, but visible in dashboards.
- New config surface requires `.env.example` documentation.
- The bundled parking list needs periodic maintenance to stay current
  (documented in the file header).

## Verification

- Unit: `dns-breaker.test.ts` (registry, keys, open/skip/half-open recovery,
  fail-open on bookkeeping errors, abort non-trip), stage strictness tests
  (requiredAvailable=2 + tertiary-only rescue → Unknown; tertiary veto still
  blocks), factory tests (breaker wiring + Redis distributed mode), config
  defaults, parking default-list fallback.
- Integration: `ci:backend` full gate (typecheck, build, lint, format, tests
  with coverage thresholds 70/65/60).
- Operational: Prometheus shows `dominus_dns_breaker_open` after repeated
  resolver failure; strict deployment logs `unverifiable` instead of
  `tertiaryRescued` when the secondary is down.