For the technical audience

Decisions ADR-101 to ADR-150

Every architecture decision record (ADR) behind TelltaleDNS: what we knew, what we chose, and what it costs. This page is built from the specification on every change, so it's always the current record.

ADR-101 A snapshot keeps at most [filter] max_regexes regex rules, 1,000 by defaultProposedCode review of v0.2.0, finding 02-08 (owner's answer, 2026-10-07: a cap, with the overflow reported).

Context

Code review of v0.2.0, finding 02-08 (owner's answer, 2026-10-07: a cap, with the overflow reported). Regex rules are tried on every query that no exact or suffix rule settles, and nothing bounded their number. Measured with matcher_bench on synthetic patterns sharing a prefix: 200 cost 0.4 µs per query at the median, 1,000 cost 1.1 µs, 1,500 cost 1.6 µs (41 MiB), and 2,000 grew past 5.9 GiB of memory within two minutes. The cause inside the regex engine's automaton wasn't pinned down.

Decision

The compiler keeps the first max_regexes rules, in list order then line order (default 1,000, 0 = no limit, at most 100,000), and reports the rest as rejected regexes: per list in the manifest (regex_skipped), on the Lists page and GET /api/v1/lists, as telltale_list_regex_skipped{list}, and in the log and telltale lists compile. The default sits below the measured cliff with room for lists whose patterns are heavier than the synthetic ones.

Consequences

A list shipping more regexes than the limit loses its tail silently to DNS but loudly to operators; raising the limit is the operator's call, with the measurements in docs/running.md. If the engine's behaviour is later understood or fixed, the default can rise. bench-smoke doesn't yet include the 1,000- and 2,000-pattern points the review asked for.

ADR-102 Privacy levels cover everything kept or shownProposedreview 04-04 and 04-05.

Context

review 04-04 and 04-05. spec/06 §4 defines privacy levels 0–3 ("Pi-hole parity"), and §7.1 says anomaly findings honour them. The levels were applied to the query log, the live tail, and event sinks, but not to rollups.db's hourly top lists, the in-memory analytics the API serves (top lists, a device's page, first-seen domains, anomaly findings), or the anomaly engine. Level 1's hash is unsalted, so anyone holding a log can confirm a guessed name.

Decision (the owner chose Pi-hole's reading, 2026-10-08):

  • The levels govern everything kept or shown. The aggregator thread applies them once, before the in-memory analytics see an event (Hub::set_privacy): names hashed exactly as the query log stores them at 1 and above, client addresses dropped at 2 and above. The query log, tail, and sinks keep applying the level themselves, so nothing is hashed twice; rollups.db takes its top lists from the analytics and drops the client list at 2 and above.
  • The anomaly engine is off at 1 and above, as it was at 3: its findings name domains and devices, and the DGA score would read hashed names as random. New-device alerts come from it, so they stop too. The masked-client detector is off at 2 and above, where every query looks like one client.
  • Counts, graphs, and latency percentiles work at every level.
  • Level 1's hash is keyed (review 04-05, owner's choice of a per-cluster key): each node creates a random key in <data_dir>/privacy.key; a primary publishes its key in every manifest (privacy_key, ignored by older builds) and the other nodes, resolver pods included, adopt it, so names hash the same everywhere and shipped logs group with the primary's. An emergency primary passes on the key it inherited. Logs written before the change keep their unkeyed hashes.
ADR-103 Acknowledged anomalies travel with the configurationProposedthe owner asked (2026-10-08) for a way to acknowledge anomaly findings and have the whole cluster honor it.

Context

the owner asked (2026-10-08) for a way to acknowledge anomaly findings and have the whole cluster honor it. Findings were per node (each node learns from the queries it answers), the Anomalies page and badge showed only the node answering the API, and the anomaly alert rule, evaluated on the primary, saw only the primary's findings. Ephemeral resolver pods serve no UI, so their findings were never visible at all.

Decision

  • Findings are federated: GET /analytics/anomalies gathers every node's, merges the same finding seen by several nodes (one entry, nodes lists them), and the alert rule therefore sees them all.
  • Each finding has a stable ID: its window start (8 hex digits) and an FNV-1a hash of kind, device, domain, and window length (8 hex digits). Every node computes the same ID, and the ID says how old the finding is, so acknowledgements can be pruned without looking findings up.
  • Acknowledgements live in state.db (anomaly_acks: ID, who, when, note, window start). The primary records them; a replica forwards the write over the cluster channel (as configuration writes are, ADR-054) and records it locally at once so its own pages show it. The primary publishes the whole set as a blob in every manifest (anomaly_acks, ignored by older builds), next to users and tokens (ADR-045); replicas replace their copy with it, so a node that was down catches up when it reconnects. An emergency primary passes on the set it inherited.
  • Acknowledging isn't a configuration change: it's allowed in a GitOps-managed cluster, like pausing blocking. It needs the operator role (agents: ops:anomalies, an immediate op without a plan) and is audited as anomaly.ack / anomaly.unack.
  • Kept 60 days from the finding's window, at most 10,000, so the published document stays small.
  • Acknowledged findings stay in the list (hidden unless "Show acknowledged") and keep their evidence; the badge counts only unacknowledged ones, and anomaly alerts skip them.

Consequences

an acknowledgement is a new configuration version (seq), like a user change: replicas apply it with a reload, and the Cluster page's timeline shows the version. Acknowledging while the primary is unreachable fails with 503 on a replica, like other writes.

Builds on ADR-045 ADR-054

Referenced by ADR-118

ADR-104 One health level: healthy, degraded, severeProposedthe owner asked (2026-10-08) for a health icon next to the navigation's project links, with three states: healthy, degraded (e.g.

Context

the owner asked (2026-10-08) for a health icon next to the navigation's project links, with three states: healthy, degraded (e.g. a cluster node down, clients rate-limited), and severe (an upstream not answering, the system not working). The signals exist (circuit breakers, the cluster view, the query counters, list state, host samples), but nothing combined them, and some (upstream health, readiness) were only ever read on the node answering the API.

Decision (owner's answers, 2026-10-08):

  • GET /api/v1/system/health (and the MCP tool health) returns level and reasons (level, stable code, summary, node, a UI link). In a cluster every node is asked for its own conditions (a federated read, like the dashboard); the answering node adds the cluster's (peers unreachable or behind). A peer that doesn't answer is listed in missingNodes.
  • Severe: every upstream of a group not answering (open or half-open breaker); no node serving DNS; SERVFAIL for 25% or more of the last 5 minutes' queries (at least 50).
  • Degraded: one upstream down while its group still answers (the owner's call: one upstream down is degraded); a node unreachable, not serving while others serve, or behind on configuration for a minute; a list failing to download; any query rate-limited in the last 5 minutes (a device asking faster than [ratelimit] allows); SERVFAIL 5% or more; a data disk over 90% full.
  • Device anomalies don't count (the owner's call: otherwise findings would have to be acknowledged to get the icon green).
  • No extra hysteresis: every condition already has a window (the breaker's, 5 minutes, the 15 s heartbeat), so a single failed lookup doesn't change the level.
  • The UI polls every 30 s. The icon changes shape as well as color (circle ✓, triangle !, octagon ×), so it reads without color; on phones, where the sidebar is hidden, a dot on the menu button shows when the level isn't healthy.

Consequences

a health read costs one federated round (bounded by the 2 s deadline) and a few local reads; nothing touches the query path. Thresholds are fixed for now; making them configurable is a follow-up if the defaults don't fit.

ADR-105 Service-level objectives from the time buckets, with multi-window burn ratesProposedOBSthe owner asked (2026-10-08) for SLOs with burn-rate alerts, an error-budget card in the UI, and the burn folded into the health level (OBS-016).

Context

the owner asked (2026-10-08) for SLOs with burn-rate alerts, an error-budget card in the UI, and the burn folded into the health level (OBS-016). The data was there (per-minute and per-hour counts by RCODE, latency histograms on /metrics), but nothing turned it into objectives, and the time buckets had no latency information.

Decision

  • Two objectives, configured in [slo] (on by default): availability, responses that aren't SERVFAIL (99.9%), and latency, timed queries answered within latency_ms (99% within 250 ms). The event sets match the Prometheus series exactly: availability over telltale_responses_total (every RCODE), latency over telltale_query_duration_seconds_count (every status but dropped), so the UI and Prometheus agree. DNSSEC failures and broken authoritative servers count as SERVFAIL: that's what devices saw.
  • latency_ms must be a bucket bound of telltale_query_duration_seconds (1 … 5000 ms), so a recording rule can compute the same SLI from le. The aggregator counts answers slower than it per time bucket (slow); the rollups store it as a tagged section after the named groups, which older builds skip.
  • The status is a pure function over the federated time buckets (minutes for 6 hours, hours for the window), in telltale-api (slo::status), shared by the route, the alert rule, and health. Nothing new runs on the query path.
  • Warnings use the SRE workbook's multi-window, multi-burn-rate pairs, tuned for a 30-day window: fast 14.4× (1h and 5m), slow 6× (6h and 30m), ticket 1× (3d and 6h). Fast and slow degrade the health level (slo_burn) and fire slo_burn rules; ticket is shown only.
  • Conservative choice (not in the request): a pair needs ≥ 50 answers in its short window and ≥ 10 bad answers in its long one, so one SERVFAIL among 20 answers at night on a home network doesn't page. The Prometheus alerts carry the same minimums.
  • Prometheus gets telltale_slo_objective_ratio{slo} and telltale_slo_latency_threshold_seconds; the chart's prometheusRule.slo values render recording rules and alerts, and deploy/prometheus/build.sh renders the same group as a plain rules file. The chart's values must be kept equal to [slo] by hand (the chart can't read the nodes' configuration).

Consequences

the SLO read costs one federated round of time buckets (minutes for 6 hours, ~720 hour rows). Hours stored before this release have no slow count, so the latency objective reads them as all quick until they age out of the window. A target of 100% is refused (no budget).

Requirements OBS-016

ADR-106 Cache sizing from a sampled ghost list of evicted keysProposedOBSthe owner asked (2026-10-08) for cache sizing advice from a ghost list (OBS-021), without costing DNS performance.

Context

the owner asked (2026-10-08) for cache sizing advice from a ghost list (OBS-021), without costing DNS performance. The memory gate (T10.2) is failing, so the estimate itself must stay small; a full ghost list (one record per evicted key) would cost tens of bytes per cached answer.

Decision

  • Spatial sampling (SHARDS, Waldspurger et al., FAST '15): only keys whose fingerprint has bits 32–35 clear (1 in 16; the shard uses the high bits and the map the low ones, so the sample is spread over every shard). A sampled key's reuse distance is exact, so the counts scaled by 16 estimate the whole cache.
  • Distance in evictions, compared with the shard's live entry count: S3-FIFO isn't LRU, so "N more entries would have held it" is an approximation, good enough to separate "1% more hits" from "nothing to gain"; the advice says "estimated".
  • A key counts only if its answer would still have been fresh when it came back (expiry on the entry's own clock), so a bigger cache isn't credited with answers it would have had to fetch again.
  • Recorded in insert (an upstream answer arrived) and eviction only: the lookup path, hit or miss, is unchanged. Memory: about 50 bytes per sampled ghost, at most 2 × live/16 + 16 per shard (~3 bytes per cached answer).
  • Advice thresholds (conservative, not in the request): grow when a step adds ≥ 1 percentage point of hits (the smallest such step); shrink only with no evictions after 100,000 lookups and a peak under half the budget (to the peak plus half, at least 4 MiB); nothing before 10,000 lookups.

Consequences

counts restart with the process. The peak is the sum of shard peaks (an upper bound), which only makes "shrink" less likely. Each cluster node judges its own cache.

Requirements OBS-021

ADR-107 Synthetic probes through the upstream client, kept out of the analyticsProposedOBSthe owner asked (2026-10-08) for synthetic probes of every listener and protocol, plus listener certificate expiry (OBS-020).

Context

the owner asked (2026-10-08) for synthetic probes of every listener and protocol, plus listener certificate expiry (OBS-020). /readyz only says the sockets are bound; nothing checked that a listener answers, and only the cluster certificate's expiry was exported.

Decision

  • Each node probes its own listeners (so in a cluster every node is checked, resolver pods included) with the upstream client it already has for every protocol: a probe is a real client of the listener. Wildcard binds are asked on loopback. TLS verification is off (the probe connects by IP); expiry comes from the certificate file instead.
  • The question is probe.telltale.invalid A: RFC 6761 makes .invalid NXDOMAIN, which the special-name handling answers without an upstream, so a probe tests TelltaleDNS and not the internet. Any well-formed answer is a success (a REFUSED from tight allowed_networks still proves the listener answers).
  • Probe queries pay the normal query path, so they're counted by the counters (a few per minute), but the telemetry thread drops their events before the analytics, the query log, and the exports (a byte comparison per event there; nothing on the query path). They skip dnstap's forwarder observer (UpstreamOptions::observe); dnstap's client-side tap still sees them.
  • PROXY-protocol listeners are skipped (the client doesn't send the header) and say so.
  • Health (conservative, not in the request): two failures in a row degrade, so one dropped UDP packet doesn't; a node whose every listener fails is not_serving (and softened like it in a cluster); a certificate within cert_warn_days (14) degrades, an expired one is severe.
  • Extra targets ([probe] targets, by IP) cover the path devices use: a load balancer's address. Hostnames are refused (no resolver to trust for the probe's own name).

Consequences

a new upstream client per probe per round (connection setup included, which is part of what's tested). An extra target that isn't TelltaleDNS answers with whatever it answers, which still counts as answering.

Requirements OBS-020

ADR-108 Upstream truth checks: sampled second opinions compared by RCODE and addressesProposedOBSthe owner asked (2026-10-08) for upstream truth checks: sampled comparison with a second upstream, and per-upstream DNSSEC-failure and EDE breakdowns (OBS-019), without costing DNS performance.

Context

the owner asked (2026-10-08) for upstream truth checks: sampled comparison with a second upstream, and per-upstream DNSSEC-failure and EDE breakdowns (OBS-019), without costing DNS performance.

Decision

  • Second opinions are off by default (sample_every = 0): the second upstream sees the sampled names, a privacy choice the owner should make per network. A sample below 1 in 100 warns.
  • They run after the client's answer, in their own task, at most 8 at once (more are skipped, never queued), from the asynchronous upstream path only. Sampling is one atomic counter.
  • The comparison is deliberately coarse: response code plus the set of A/AAAA addresses. filtered (one side resolves, the other gives nothing or a sinkhole) is the signal that matters; different_addresses is expected from CDNs and shown as such, not as an alarm. TTLs, record order, and other types are ignored.
  • The second opinion comes from the reference group when set (its strategy picks the member), else from another member of the answering group: comparing upstreams the user already trusts with the same question. A one-member group gets none unless a reference is set.
  • EDE codes and DNSSEC verdicts are counted for every upstream answer (no sampling): a scan of the answer's records for the OPT, a lock only when an EDE is present, and an atomic increment per verdict. The (upstream, code) map is capped at 2,048 pairs.
  • Counts are since start, per node; nothing alerts or changes health on its own (no thresholds the owner has agreed to yet).

Consequences

second opinions add upstream traffic (1/N) and a little upstream load. The second exchange is ordinary upstream traffic: it updates that upstream's health and appears in dnstap's forwarder messages.

Requirements OBS-019

ADR-109 Shadow lists decided again on the telemetry thread; over-blocking from retries and quick allowsProposedOBSthe owner asked (2026-10-08) for shadow mode for lists (count what a list would block for a week without enforcing it) and an over-blocking view (blocked names that are retried or allowed right after), within the no-performance-cost condition (OBS-018).

Context

the owner asked (2026-10-08) for shadow mode for lists (count what a list would block for a week without enforcing it) and an over-blocking view (blocked names that are retried or allowed right after), within the no-performance-cost condition (OBS-018).

Decision

  • A shadow list is compiled into the same snapshot (no second compile, no extra memory beyond its names) and left out of every enforcing mask, so the query path is unchanged: it never consults shadow lists at all.
  • The would-be decision is made off the query path: a telemetry-thread sink re-runs Matcher::decide for answered queries with the group's mask plus its shadow lists. This costs a filter lookup (about 0.3–1 µs) per answered query on the telemetry thread, and only while some list is in shadow mode. If the thread falls behind, events drop (counted) and DNS is unaffected. The group comes from the event (the primary group); a device whose own client entry names extra groups is judged by its primary group only.
  • Over-blocking evidence uses only what the events already carry: retry bursts (10 blocked queries in 60 s from one device; ad libraries do this too, so it's ranked low) and allowed_after_block (the same device answered within 10 minutes of a block: a pause or a quick allow; ranked ×10). Thresholds are fixed for now (conservative, not in the request).
  • Bounded state: recent blocks 8,192, suspects 512, devices 4,096 per shadow list and 64 per suspect, 256 tracked names per list. Since start, per node; reads merge every node's.
  • Names follow the privacy level (hashed at 1+); devices aren't counted at 2+.

Consequences

counts reset on restart (Prometheus keeps telltale_list_shadow_hits_total history). Turning a list from shadow to enforce is an ordinary list change (API, UI, MCP plans).

Requirements OBS-018

Referenced by ADR-115

ADR-110 Exemplars and traces from query events; trace IDs the query log can findProposedOBSthe owner asked (2026-10-08) for OpenMetrics exemplars on the latency histograms and sampled/slow queries as OTLP traces (OBS-017), without costing DNS performance.

Context

the owner asked (2026-10-08) for OpenMetrics exemplars on the latency histograms and sampled/slow queries as OTLP traces (OBS-017), without costing DNS performance. The latency histogram is counted on the query path in per-thread atomics; nothing there knows a query's identity, and adding it would cost every query.

Decision

  • Exemplars and traces are built on the telemetry thread from the query events it already drains (a Sink), never on the query path. An exemplar slot per (path, bucket) is replaced at most once a second, so the per-event cost is a bucket lookup and a comparison; the trace ID is hashed only when a slot is refreshed or a query is traced.
  • Trace ID = start time ‖ keyed hash of the query-log row, not a random ID: ts_us (8 bytes) ‖ BLAKE3 keyed with the cluster's privacy key over ts_us, client, qtype, name as the query log returns them (after the privacy level). No storage change: the query log finds it by searching that microsecond and recomputing the ID per row, on any node (the key is the cluster's) and across a mixed-version cluster (the route checks after federation). A random ID would need a new query-log column; an unkeyed hash would let anyone with an ID confirm a guessed name. It's not random as W3C recommends; the time it reveals is already in the exemplar.
  • OpenMetrics is served only when the scraper asks (Accept), by converting our 0.0.4 exposition (one writer, two formats). Prometheus asks for it by default, so it's verified against Prometheus 3.5 and the strict prometheus_client parser, with [telemetry.metrics] exemplars = false as the way back.
  • Traces reuse the OTLP endpoint (JSON, like metrics and logs; no new dependency), off by default (traces_sample_every, traces_slow_ms). Spans come from the event's timings only: a server span and an upstream client span placed at the query's end (the event has the wait's length, not its start). Bounded queue (4,096), dropped and failed counted.
  • Only telltale_query_duration_seconds gets exemplars: upstream exchanges have no query name (their histogram stays as is).

Consequences

an exemplar is a recent example per bucket, not the worst one. Trace IDs of queries before a privacy-key change (joining a cluster) no longer match their rows. Privacy level 3 still sends traces (hashed names, no clients), like event sinks.

Requirements OBS-017

ADR-111 Exclusions applied by the aggregator, a shared one-of section managed like the rate limitProposedOBSthe owner picked "keep names and devices out of the log and stats" (OBS-022, Pi-hole's most-voted logging request) and asked for a toggle in the settings and in GitOps.

Context

the owner picked "keep names and devices out of the log and stats" (OBS-022, Pi-hole's most-voted logging request) and asked for a toggle in the settings and in GitOps.

Decision

  • One shared section, [exclusions] { enabled, names, clients }, outside [telemetry] (which stays with each node) so a cluster excludes the same everywhere. enabled is the toggle that keeps the lists.
  • Applied on the aggregator thread when events are drained: a match is dropped before the analytics and every sink (query log, tail, anomalies, event sinks, shadow lists, exemplars, traces), and counted (telltale_queries_excluded_total). The hot-path Metrics counters still count it, so Prometheus totals stay complete and DNS cost is zero. The startup replay skips them too.
  • Managed like [ratelimit] (ADR-069's one-of kind, now shared code): PUT/DELETE /api/v1/exclusions/default checked and applied, Settings → System with Revert to the file, MCP plan_set_exclusions, scope config:write:exclusions; under a GitOps authority, refused with the TOML to commit.

Consequences

the dashboard's totals no longer match Prometheus when exclusions are on (documented). Excluded queries can't be searched later. Up to 1,000 names and 1,000 clients (validated) keep the per-event check cheap.

Note (2026-10-09): the owner also picked "alert when a new device joins"; that exists already (alert rule new_device, OBS-010/T9.5, cluster-wide), so nothing was built. It's off at privacy level 1 and above.

Requirements OBS-010 OBS-022

Builds on ADR-069

Referenced by ADR-115 ADR-116

ADR-112 Stale lists: content unchanged for 30 days, URL lists onlyProposedOBSthe owner picked stale-list detection (OBS-023, from Technitium #2198 and Pi-hole's "adlists that haven't changed in a long time"): a list whose maintainer stopped still downloads with HTTP 200 and the same body, so nothing fails.

Context

the owner picked stale-list detection (OBS-023, from Technitium #2198 and Pi-hole's "adlists that haven't changed in a long time"): a list whose maintainer stopped still downloads with HTTP 200 and the same body, so nothing fails.

Decision

a URL list is stale when its last_changed (the body's BLAKE3 changed; identical downloads don't count) is at least stale_after_days ago: [filter] default 30 days (a conservative value: the popular lists update daily or weekly), per list too, 0 = never. Exposed as ListInfo.staleDays, health reason list_stale (degraded, like a failing list), alert rule list_stale, and a line on the Lists page. Files and inline rules are never stale (they change when you change them).

Consequences

a list that's fine but rarely updated needs stale_after_days = 0; documented.

Requirements OBS-023

ADR-113 NSID from the node's name, only when asked, appended to our OPTProposedDNSthe owner picked NSID (DNS-021, from Technitium #1932): behind one address (a load balancer, resolver pods, the homelab's .112), there's no way to tell which node answered.

Context

the owner picked NSID (DNS-021, from Technitium #1932): behind one address (a load balancer, resolver pods, the homelab's .112), there's no way to tell which node answered.

Decision

[dns] nsid (default off: any allowed client sees the name) adds an NSID option with this node's name ([node] name, else the host name, a pod's name in Kubernetes) to answers whose query carried the NSID option. It's added in finish, which every answer path goes through, by appending to our OPT record when it's the message's last record and fits; otherwise the answer goes out unchanged. The name lives in the reloadable policy, so [dns] being shared turns it on cluster-wide while each node names itself. id.server/hostname.bind (RFC 4892) stay REFUSED for now.

Consequences

queries without NSID pay one option scan of their OPT (usually empty); bench-smoke unchanged.

Requirements DNS-021

ADR-114 AAAA filtering per group, after blocking, before rewrites and upstreamsProposedFLTthe owner picked per-group AAAA filtering (FLT-016, from Pi-hole's feature requests): devices on a network with broken IPv6 wait on AAAA answers before falling back to IPv4.

Context

the owner picked per-group AAAA filtering (FLT-016, from Pi-hole's feature requests): devices on a network with broken IPv6 wait on AAAA answers before falling back to IPv4.

Decision

[[group]] filter_aaaa answers the group's AAAA questions NOERROR with no records (no SOA: clients just ask again, cheaply). Placed after local data, zones, quick rules, schedules, list rewrites, and the filter decision, so configured local IPv6 addresses still answer and blocked names still get their block answer; before rewrites, safe search, the cache, and upstreams, so nothing is fetched for it. Status special; explain reports it. Rejected with dns64 (contradictory). HTTPS records are left alone: stripping ipv6hint would mean rewriting RDATA on the query path, and the request was for AAAA.

Consequences

the cost is one comparison for non-AAAA queries and a group lookup for AAAA. Browsers that use HTTPS-record IPv6 hints may still try IPv6; documented.

Requirements FLT-016

ADR-115 Change simulation: the serving snapshot plus a side snapshot, decided again over the query log, off the query pathProposedAGTOBSthe owner picked change simulation (OBS-024, 2026-10-09).

Context

the owner picked change simulation (OBS-024, 2026-10-09). AGT-002 promises an impact estimate on every dry run; today a dry run reports the diff and validation only. Shadow lists (ADR-109) already re-decide live queries on the telemetry thread; the query log holds the recent past. Design doc: docs/design/change-simulation.md.

Decision

  • One engine, three doors. simulate=<window> on any existing dry run (?dryRun=true&simulate=24h) adds an impact object to that response, so plans (which are dry runs) and the UI's change preview get it with no new body shape; POST /api/v1/simulate takes a whole candidate shared configuration (for GitOps, telltale config simulate); MCP simulate_change wraps the first for the plan tools' change kinds. Dry runs without simulate are unchanged. Plans simulate only on request (owner 2026-10-09, to save compute): a plan_* tool simulates when the agent passes simulate, or when [simulate] plans_by_default = true; the UI's preview is a button, never automatic.
  • [simulate] is a shared one-of section managed like [exclusions] (ADR-111): enabled, plans_by_default, max_secs, max_rows, default_window; Settings → System → Change simulation; PUT /api/v1/simulate-settings; GitOps says what to commit. Shared rather than node-local so the toggles are one place to look; the bounds suit a Pi and a pod alike.
  • Decisions, not resolution. The simulation re-decides each logged query under the candidate state and compares the outcome with the one recorded: blocked ↔ allowed, local/rewrite/AAAA-filter changes, and the route (upstream group). Nothing is fetched from upstreams, no cache, no DNSSEC, no latency.
  • No full recompile. Group, device, quick-rule, schedule, and block-mode changes reuse the serving snapshot with recomputed masks. A change that adds or edits lists compiles only the changed lists into a side snapshot (one idle-priority thread, the compiler's memory cap) and merges Matcher::matches from both snapshots by the existing precedence (exact > deepest suffix > regex > lowest list ID). $badfilter across the two snapshots isn't honored (reported as an approximation). This keeps a Pi with 2.7M names from a 300 MiB compile peak (ADR-025) for every preview.
  • Bounded. One simulation at a time per node (409 simulation_busy), max_secs (20) and max_rows (2,000,000) with partial: true when hit, default window 24 h, maximum 7 days. Rows come from the query log's column reader newest first; the aggregation is counts and Space-Saving top-K per outcome, by device and by name. Privacy level 1 and above hashes names, so name-dependent simulations answer unavailable with that reason.
  • Where it runs: on every node in scope that holds a log (local, or shipped logs it receives) as an RPC with its own deadline (max_secs + 5), merged at the entry node like other federated reads; resolver pods in ship mode are covered by the controller's copy of their rows.
  • The result is deterministic for the same log and candidate (fixed ordering, no wall clock). Agents need querylog:read plus the write scope of the change, as for pre-save checks; simulations aren't audited (dry runs aren't).

Consequences

a preview can take seconds on a Pi; the UI asks for it on a button, plans compute it only when asked (or when the toggle says so) and continue without it when the node is busy or the window empty. The estimate is about the past: a new list's effect on names nobody has asked for yet is zero by construction, and the response says so. Amended (T13.1, 2026-10-09; still Proposed):

  • Compared with the configuration in effect, decided again; not with the recorded status. Comparing the candidate's decision with what the log recorded counts everything the change doesn't cause: blocking paused at the time, CNAME-target blocks (decided on the upstream's answer, which a replay doesn't have), lists that updated since, rate limits. Both sides now run the same steps over the same rows, so a change that affects nothing reports zeros (AC 1). Pauses aren't replayed on either side.
  • Refused rows are replayed (the design skipped them): a query refused because no upstream group serves it went through quick rules and the filter first, so a block for it is a real difference; one refused by allowed_networks is refused again on both sides. Rate-limited, malformed, and dropped rows are still skipped (counted unchanged).
  • The field is simulation, not impact. ConfigChange.impact and ClientChange.impact already exist (the dry run's one-sentence AGT-002 estimate); renaming them would break clients. The roadmap's "impact" means this object.
  • No has_qlog heartbeat bit. The entry node sends sim.run to every reachable peer; a node without a log of its own (ship mode, query log off) answers an empty tally. One RPC more per peer, no wire change, and N−1 nodes simply answer an error and are listed in missingNodes. The request carries both configurations' shared parts, so each node applies its own node-local settings (privacy level, data directory) and refuses what its own privacy level forbids. Deadline max_secs + 30 s (peers may download a changed list first), not + 5.
  • simulate=auto is what MCP plan tools send by default: it simulates only when plans_by_default is on, and is silently skipped for a token without querylog:read. simulate=true means default_window. simulateUntil moves the window's end back. Problem codes simulation_busy (409, Retry-After: 5) and simulation_window (422).
  • Privacy level 1, device-only changes: a change to [[client]] alone is replayed by address and logged group: a device's queries are unchanged when its new groups decide alike (the same settings apart from name, networks, priority, color), else undetermined (a new counter), since hashed names can't be decided again.
  • Aggregation: exact counts in bounded maps (50,000 names per kind, 4,096 devices, then other; note names_capped) instead of Space-Saving: simpler, exact under the bounds, and mergeable across nodes by addition.
  • Scopes: a dry run with simulate from an agent needs querylog:read; a token restricted to one group can't simulate (it would read every device's queries). POST /api/v1/simulate needs querylog:read and config:read, and no X-Telltale-Reason (it changes nothing). An agent sees "a list couldn't be downloaded" without the reason (AGT-009, as for check_list). Settings: agent scope config:write:simulate.

Requirements AGT-002 AGT-009 OBS-024

Builds on ADR-025 ADR-109 ADR-111

ADR-116 Staged rollouts: targeted delivery of canary versions, a guard from heartbeats, and pins instead of rewindsProposedCLUthe owner picked canary rollouts with an SLO guard and one-click rollback (CLU-013, 2026-10-09).

Context

the owner picked canary rollouts with an SLO guard and one-click rollback (CLU-013, 2026-10-09). Today the primary publishes every version to every node at once; a bad list or upstream change reaches the Pi and the pods together. Versions are signed and ordered by (epoch, seq), orphan detection and fencing depend on that order, and N−1 replicas apply whatever newer manifest they see. Design doc: docs/design/staged-rollouts.md.

Decision

  • Two heads, targeted delivery. The primary keeps a stable head (what every node should run) and, during a rollout, a canary head. A canary manifest is sent only to canary peers, and a reconnecting peer is answered with the head for its set; the rollout field in the manifest is for display and for the replica to know what it runs. Older replicas therefore never see canary versions and need no code: N−1 nodes are non-canary. Who is a canary: [cluster.rollout] canaries (node IDs, site:<name>, or ephemeral). Off until set (owner 2026-10-09): the default is an empty list, so nothing changes for existing clusters; [cluster.rollout] is a shared one-of section managed like [exclusions] (ADR-111: PUT /api/v1/cluster/rollout-settings, Settings → Cluster → Staged rollouts with the canary picker listing the cluster's nodes and sites, Revert to the file, the TOML to commit under GitOps). A change to this section itself never goes through a rollout.
  • The primary applies at once, as today, and counts as a canary for the guard. Decoupling what the primary serves from what it publishes would be a second architecture; in the reference topology the primary is the Kubernetes controller, and the Pi is a plain replica that waits. Recorded as the limitation it is.
  • The guard reads what heartbeats already carry (serving stats every 5 s, probe status, readiness, apply errors) over bake_secs (300): fail at once on an apply error, a canary not ready, or a canary's listener probes failing; at the end, fail if the canaries' SERVFAIL share rose above max(before + 2 points, servfail_pct) with at least 50 answers, or an objective burns at the fast rate on a canary. Too little traffic passes (require_traffic = false): quiet networks shouldn't block changes. Pass promotes the stable head; promote and abort are always available to an operator.
  • A rollback is a pin, never a rewind. seq stays monotonic (fencing and orphan detection rely on it). Pinning to version N makes the primary publish N's blobs (config + filter, already in the blob store) as the stable head, apply them to itself the way an emergency primary serves an inherited version, and refuse configuration writes with 409 cluster_pinned and the JSON diff between N and the current source. The pin travels in the manifest, so a new primary after a failover stays pinned; Git polling continues and publishes nothing. Unpin resumes from the current source (a new rollout if guarded). Reverting the source to N (rewriting managed entries, or Git) is deferred: the operator sees the diff and fixes the configuration.
  • Kept versions: the primary keeps the last history (20) manifests in <cluster dir>/versions.json and protects their blobs from pruning; GET /api/v1/cluster/versions lists them with provenance, stage, and outcome; any of them can be pinned.
  • Alerts rollout_failed (once per version) and cluster_pinned (while pinned); health degraded cluster_pinned; events on the Cluster page timeline; MCP rollout_status (read), plan_pin_version/plan_unpin (cluster:admin, plan/apply), cluster_promote's approval rule unchanged.

Consequences

during a bake the cluster deliberately runs two configurations; the Cluster page shows which nodes run which. An emergency primary never starts a rollout (it publishes nothing new). A canary set that's empty or all offline makes the rollout wait, not pass; abort or an empty canaries setting ends it. Simulating a 300 s bake in CI needs bake_secs to accept small values (tests use 5). Amended (T13.2, 2026-10-10; still Proposed):

  • No SLO burn in the guard. slo::burning works on the cluster's merged minute rollups, not per canary, so a burn can't be pinned on a version; the SERVFAIL share over the canaries already measures what the objectives do. Deferred.
  • SERVFAIL is judged during the bake, not only at its end: as soon as the canaries and the primary have 50 answers since the version went out, a share over max(before + 2 points, servfail_pct) fails it, so a bad version is pinned seconds after it went out and the waiting nodes never get it (AC 2: in the e2e, 5 s into a 20 s bake). A pass still waits for the end of the bake.
  • Heartbeat fields after all (the design said none were needed): rollouts (tag 24, this node takes part; an older node is never sent a canary version, even when named), sync_error (25) and probe_failing (26) (the primary had no per-peer apply error or probe state), and canary_of (27: while a node runs a canary version, the stable seq it would replace, so configuration lag, sync_lag, and the behind alerts count stable versions only and the nodes waiting out a bake aren't "behind"). All optional; older nodes send and ignore them.
  • Promotion publishes the canary's content as a new seq without the rollout field, instead of re-sending the canary manifest: no node keeps a manifest marked canary after the bake, and the plain replicas never apply the canary's seq (AC 1).
  • Commands run on the primary's publish loop (a queue the API feeds; a replica's request travels the cluster channel as the RPC rollout.call and is audited on both nodes). State lives in <cluster dir>/rollout.json (the bake, the pin), versions.json, and pinned.json (the pinned manifest the primary serves itself) rather than in published.json.
  • A restart of the primary during a bake starts the rollout over (the version baking is marked superseded and the content goes to the canaries again). After the publisher starts it waits up to 60 s for a canary to connect before a change goes to every node, so a restart doesn't skip staging.
  • Canaries all offline: changes reach every node after that minute (design §4; the Consequences above said "wait"), logged as "rollout skipped: no canary online" and shown on the Cluster page. Canaries set with none online for 10 minutes is the health condition rollout_stuck (degraded): changes are going out without a bake.
  • Users, tokens, the member list, and acknowledged anomalies wait for a bake to end before they're published (a version for everyone would carry the canary's configuration).
  • The pinned filter is installed as snapshots/pinned-<version> beside the compiled snapshots; while pinned, list refreshes still download and compile on the primary but no other snapshot is served or published (filter_pin). Unpinning serves the newest compiled snapshot again.
  • CLI under telltale ctl (rollout status|promote|abort, versions, pin, unpin): the commands need the running node and an API token, as maintenance does; telltale cluster stays offline.
  • Agents pin only with approval: plan_pin_version and plan_unpin refuse unless [agents] require_approval = true, as spec/13 asks for promotion.
  • Problem bodies gain diff (JSON Patch) for cluster_pinned; it crosses write forwarding.
  • Version provenance is "this node's configuration" or "Git commit …", not the user or agent behind each change (the audit log has those). A node promoted to primary starts its own history (plus the version it was running), so versions only the old primary kept can't be pinned there. Deferred: per-change provenance in the history.
  • Found and fixed on the way: a node outside Kubernetes couldn't apply the controller's configuration in the Helm scaled e2e. Blob fetches went to the primary's first advertised URL (the in-cluster Service name) instead of the URL the node's stream uses. They now prefer the stream's URL. This would also have hit a Raspberry Pi joining the homelab.
  • Metrics also include telltale_cluster_rollouts_total{outcome}, which the chart's TelltaleDNSRolloutFailed uses; TelltaleDNSClusterPinned fires after an hour.

Requirements CLU-013

Builds on ADR-111

Referenced by ADR-121

ADR-117 Device identification from shipped signatures: vendor, names, and the domains a device talks toProposedAPIOBSthe owner picked device identification (OBS-025, 2026-10-09): a new address on the network is a number, and naming it (API-010) means knowing what it is first.

Context

the owner picked device identification (OBS-025, 2026-10-09): a new address on the network is a number, and naming it (API-010) means knowing what it is first. Everything needed is already observed: the MAC (neighbor table, EDNS MAC, router leases), announced names (mDNS, router DHCP), and the registrable domains the device queries (the anomaly engine's learned set and the per-client top domains). Design doc: docs/design/device-identification.md.

Decision

  • Deterministic scoring over a shipped catalog, no model. presets/devices.toml holds signatures (id, name, class, vendors, domains with weights, hostname_patterns); presets/oui.bin holds the IEEE MA-L/MA-M registry compacted (3- and 4.5-byte prefixes → vendor index), built by presets/build-oui.py from IEEE's public CSV, never from another project's manuf file. A device's score for a signature is 0.6 × (matched domain weight ÷ total) + 0.25 × vendor + 0.15 × hostname; the best signature is reported as likely (≥ 0.6), possibly (≥ 0.35), else unknown with the vendor alone. Every result carries its evidence (which domains, the vendor string, the matched name). Same inputs, same answer, on every node and architecture (f32, fixed ordering), like ADR-019.
  • Off the query path, bounded. A background task every 10 minutes scores the devices seen in the last day (max_clients, 1,024), using the aggregator's per-client top domains and, for devices it doesn't hold, one idle-priority query-log top-K per device. Results live in memory and <data_dir>/devices.json; nothing runs at privacy level 1 or above (names are hashed).
  • Suggests, never acts. The class maps to a group only when a group declares [[group]] device_classes = [...]; the UI then offers "Add to IOT?" through the existing Add-to-group action and plans. [[client]] kind = "camera" (or "unknown") is an explicit override that wins and replicates with the configuration. New-device alerts and anomaly findings quote the guess.
  • One shared section [identify] { enabled, max_clients, signatures_file } (extra signatures in the same TOML shape; the shipped catalog can't be removed, only added to). The catalog updates with releases only: no network fetch of signatures.
  • Federated reads merge per device by highest confidence; GET /api/v1/clients gains identity, GET /api/v1/clients/{id}/identity gives the evidence, MCP identify_device, telltale_devices_by_class{class}.

Amended (T13.3, 2026-10-09; still Proposed):

  • Vendor words match whole words, not any substring: "Ring" must not match "Sage Electronic Engineering". Case-insensitive, word boundaries at non-alphanumerics.
  • A tie with another kind of device is possibly, never likely. Apple's devices share their domains; without a name, an Apple MAC talking to apple.com would otherwise be "likely an Apple TV" only because apple-appletv sorts first. The runner-up is reported.
  • A private (locally administered) MAC has no vendor and no prefix shown (phones' private Wi-Fi addresses).
  • Passes on demand: besides every 10 minutes (the first a minute after start), a pass runs when the API asks for identities older than 15 seconds (never waiting for it; a pass costs milliseconds): the UI and tests don't wait 10 minutes for a new device. It runs on a dedicated background-priority thread, not the async runtime.
  • GET /api/v1/clients/identities (every device seen, federated and merged) is added next to GET /api/v1/clients/{id}/identity: the "seen this hour" table and the Advanced dashboard's "Devices by type" need identities for unnamed devices, which GET /api/v1/clients (named devices) doesn't list. Agent scope analytics:read.
  • Devices seen: the aggregator's per-client top domains (this hour and the last), its top clients, and the router and mDNS leases; at most 8 query-log reads per pass for devices without domains, each refreshed after 6 hours. Per device at most 20 domains of at most 40 bytes (≤ 1 KiB, tested).
  • devices.json is written hourly and at shutdown (atomically), not on every pass, to spare SD cards.
  • The OUI table is 643 KB raw, 383 KB gzipped (25,439 vendors, 6,628 MA-M and 40,316 MA-L prefixes; vendor names normalized and capped at 32 bytes), a little over the ~300 KiB estimate.

Consequences

about 300 KiB of compressed data in the binary (measured before merge against the 15 MiB image gate). Devices behind a NAT or a Kubernetes LB without client IPs score as one. Signatures are a maintenance commitment; the catalog is deliberately small (common home devices) and ships in v1 so the owner's own network can test it (owner 2026-10-09). No "not this" feedback beyond the override in v1.

Requirements API-010 OBS-025

Builds on ADR-019

ADR-118 Maintenance mode: not ready, still answering, out of alerts and electionsProposedCLUOPSthe owner picked node maintenance mode (OPS-010, 2026-10-09).

Context

the owner picked node maintenance mode (OPS-010, 2026-10-09). Swapping a Pi's SD card or draining a Kubernetes node today means killing the process: balancers notice late, node_down and probe_failing alerts fire for a planned event, and the health icon goes red. Design doc: docs/design/maintenance-mode.md.

Decision

  • Maintenance is operational node state, not configuration: <data_dir>/maintenance.json { until_ms, reason, by }, set through POST /api/v1/nodes/{id}/maintenance (operator; forwarded to the target node over the cluster channel as an RPC, like a tail session, not through the primary), telltale ctl maintenance start|end, the Cluster page, and the MCP tools start_maintenance/end_maintenance (ops:maintenance; agents at most 2 h). Audited node.maintenance.start|end. Allowed under a GitOps authority (ADR-103's rule: it changes no configuration).
  • What changes on the node: /readyz answers 503 (the same ready flag shutdown clears; nothing else on the DNS side), heartbeats carry maintenance_until (a new optional field, ignored by N−1), the node declines to be a candidate (pre-vote) while in maintenance. What doesn't: every listener keeps answering; the cache, lists, replication, the query log, probes, and the API keep running.
  • A primary in maintenance hands over when it can (owner 2026-10-09: "follow your lead"). With automatic failover and another eligible node or quorum reachable, starting maintenance on the primary performs a handover (handover = true, the default; the form shows it): the primary stops renewing its lease and declines candidacy, so a replica is elected within the normal lease window (writes unavailable for the usual 15–30 s, as in any failover), and the old primary follows as a replica for the rest of the window. With manual failover, or no other eligible node, maintenance is allowed and the primary keeps publishing; the UI and CLI say "promote another node first if you're taking it offline".
  • What changes elsewhere: the primary's alert evaluation skips the node (node_down, sync_lag, probe_failing, cert_expiring, disk_full, not_serving for it), and firing alerts about it resolve with "(maintenance)"; the health level ignores the node's reasons and lists it under maintenance; the Cluster page's checks (load balance, serving) skip it. A once-only alert rule maintenance tells destinations when a node enters (and resolves when it leaves).
  • Bounded and persistent: default 1 h, at most 24 h (validated); the file is read at start, so a node rebooted mid-window comes back not ready until the window ends or someone ends it. That is the point: the operator, not a reboot, says when it's back.
  • Readiness has one meaning per pod. In Kubernetes the pod's readiness probe serves every Service that selects it, so a controller pod in maintenance also leaves the API Service; the UI stays reachable through any other node (CLU-002) or a port-forward. Documented; no second probe.

Consequences

clients that still ask a node in maintenance (a router's secondary DNS entry, hard-coded devices) get normal answers, so nothing breaks when the balancer is slow to notice. A node that's really failing while in maintenance stays quiet until the window ends: the window is short by default for that reason. A standalone node in maintenance has no cluster to tell; only readiness, the UI banner, and the alert rule apply. Amended 2026-10-09 (T13.4 implementation; amendment accepted by the owner 2026-10-09): built as decided, with these changes, each the conservative reading:

  • The startup probe leaves maintenance out. The chart's startupProbe also asked /readyz, so a pod restarted inside its window failed it and was killed after 120 s, over and over: the opposite of "comes back not ready". /readyz?startup=1 answers ready once listeners are bound and the configuration is applied, maintenance or not; both chart probes use it (a template change, no new values). Older binaries ignore the parameter. The readiness probe keeps plain /readyz. Docker Compose's healthcheck shows the container unhealthy during maintenance (documented; Compose doesn't restart for it).
  • Readiness is composed (crate::readiness: bound, synced, stopping, maintenance): serving (bound, synced, not stopping) is what heartbeats' ready, probes, and the health level's not_serving read, so a node in maintenance counts as serving everywhere (also on N−1 peers); ready adds maintenance and is what /readyz answers, with the reason as JSON.
  • Heartbeats carry maintenance_until_ms, maintenance_reason, maintenance_by, and maintenance_since_ms (tags 20–23; the health list and the Cluster page show who). A member's window is kept while it's silent, so a node stopped during its window stays in maintenance until until.
  • The handover gives up after 60 s (four lease windows) if no other node was elected: the primary renews again and the event log says handover_failed, so a handover that can't complete never leaves the cluster without a writable primary for the rest of the window. A handover starts only when another eligible node (in the registry, not a witness, not in maintenance) is connected and a majority of voters is reachable.
  • handover answers declined when the request said handover: false (with the promote-first note), besides started, not_needed, and unavailable.
  • Problem codes: maintenance_too_long and a new maintenance_invalid (no usable reason, or under a minute), both 422 as the task's AC asks; node_unreachable (503) also covers a node too old to know maintenance; node_unknown (404).
  • The RPC (maint.set / maint.clear) carries the requested length, not an until: the target computes the window on its own clock. It also carries when it was sent; a target drops a request more than 30 s old, so a paused process that resumes never acts on a request the user saw fail.
  • SERVFAIL share: the alert task reads the time buckets of the nodes not in maintenance (a scoped federated read), so servfail_rate leaves out exactly the maintenance node's share. SLOs are unchanged.
  • The health answer also leaves nodes in maintenance out of missingNodes (a stopped node in its window is expected to be silent).
  • Chart alerts: the node rules use unless on(instance) (last_over_time(telltale_node_maintenance_until_seconds[1d]) > time()) instead of telltale_node_maintenance == 1, so a node that stops reporting during its window (powered off for the SD card) stays quiet until the window ends; the peer-down rule uses the new per-peer telltale_cluster_peer_maintenance_until_seconds{node,site}. deploy/prometheus/ holds only the SLO rules, so nothing changed there.
  • Election: Elector::standing (false in maintenance: no pre-vote, and a candidacy in progress stands down instead of winning) and Elector::stepping_down (a leader skips renewals). The simulator puts nodes in and out of maintenance at random (surviving restarts) and checks that a node in maintenance is never elected and that a primary handing over can't write past one lease; both checks were mutation-tested.

Requirements CLU-002 OPS-010

Builds on ADR-103

Referenced by ADR-151

ADR-119 Expired answers given at once are refreshed, a cache answer; stale keeps meaning failureProposedDNSthe 2026-10-10 live review found a steady-state cache hit rate of about 6%: most repeat questions arrive just after their answer expired (devices cache too).

Context

the 2026-10-10 live review found a steady-state cache hit rate of about 6%: most repeat questions arrive just after their answer expired (devices cache too). The owner chose Pi-hole's behavior, an expired answer given at once while it's refreshed (RFC 8767 with a zero client response timer, [cache] stale_answer_client_timeout_ms = 0), with Technitium's 10 s TTL floor. Those answers were stale, the status that also says "upstreams failed or were slow", and counted on the upstream latency path, so the change would hide upstream trouble and make upstream latency look faster than it is. The owner asked that general users see one cache number and advanced users the split.

Decision

  • A new query status refreshed, appended to the status list (stored values keep their meaning; older builds read it from the query log as dropped and ignore its rollup column): an expired entry answered without waiting while the spawned resolution refreshes it. Latency path cache. stale stays the fallback after a wait, or when overloaded.
  • A zero wait doesn't wait at all (a zero-length timer would let an answer arriving within its tick win, which made the outcome depend on timing).
  • Amended 2026-10-11: the 2026-10-11 live review measured the cache path, now mostly refreshed answers, at p50 78 µs and p99 ~450 µs on the homelab, and p99 658 µs on the Pi. Fresh hits had been 25 µs and 50–90 µs, and the budget is 250 µs added latency for a cache hit (00, release gates). Lab A/B on the build VM showed the cost was starting the refresh before the answer went out: it wakes an idle runtime worker. It appeared only at low load (20 q/s: p50 ~110 µs, p99 248 µs; 3,000 q/s: p99 63 µs). So:
    • With a zero wait, the expired entry is answered on the synchronous path, like a hit. It no longer goes through a task, a full-size buffer, a re-parse, and a second lookup per answer.
    • It's refreshed through the prefetch path (one shared upstream resolution per name). On a UDP worker thread, refreshes (and DNS-008 prefetches) are queued and started by QueryHandler::sent once the batch's answers are out.
    • Result on the VM at 20, 200, and 3,000 q/s: p50 25 µs and p99 50 µs, the same as a fresh hit.
    • If the refresh can't start (overloaded), the answer is still given, as stale.
    • DNS64 clients, and an entry evicted between the two lookups, keep the deferred path. TCP, DoT, DoH, and DoQ start the refresh at once: they run on the runtime, which is already awake.
  • One number for everyone: the dashboard's cache tile, GET /stats/summary cacheHitPercent, the Cache page, and Grafana's "Answered from cache" all count fresh, refreshed, and stale answers. The split is in the Advanced view (Summary.refreshed; the status chart's refreshed series), in CacheNodeStats/CachePoint answeredPercent beside the fresh hitPercent, and in Grafana's "Answered from cache" panel (answered vs fresh only).

Consequences

the cache's own counters still call a refreshed answer a miss (and count it in stale_served); answeredPercent adds them back. With a zero wait, an expired answer is refreshed even when the background refresh then fails; upstream failures show in the upstream metrics and health. The homelab and the Pi run it since 2026-10-10 (homelab-charts 7340e1b and the Pi's own file: [cache] is per node).

Requirements DNS-008

ADR-120 Router integrations verify self-signed consoles by the name in their certificateProposeda UniFi console's own certificate is self-signed and names unifi.local (plus localhost and loopback), never the address it's reached at.

Context

a UniFi console's own certificate is self-signed and names unifi.local (plus localhost and loopback), never the address it's reached at. The documented tls_ca setup therefore always failed ("certificate not valid for name 192.168.3.1", checked against the owner's console on 2026-10-11), leaving tls_insecure_skip_verify as the only working option. With verification off, anything on the network could pose as the console and receive the API key.

Decision

  • [[router]] tls_server_name: the name the certificate is checked against (and sent as SNI) instead of the URL's host. With the console's certificate as tls_ca, only that certificate is trusted, and it's verified.
  • Validation: a host name; only for https:// URLs; an error together with tls_insecure_skip_verify (it would do nothing). The tls_insecure_skip_verify warning points at the alternative.
  • Implemented as fetch::Client::with_server_name, which applies to every request that client makes; each router gets its own client.

Consequences

  • When the console renews its certificate (the owner's expires 2027-04-13), the saved copy no longer matches. Polls fail and are logged once, and names stay as last read. DNS is never affected. Save the new certificate.
  • [[router]] is shared configuration, so every node needs the key and certificate files at the same paths. Helm resolver pods don't mount secretMounts: each logs one warning, and the controller (which serves the UI) reads the router. (Amended 2026-10-18, live review finding 1: on an ephemeral member, the resolver pod, that message and a failed OIDC setup are logged as info, not warning/error; the secrets still stay off resolver pods.)

Referenced by ADR-150

ADR-121 Long pages get tabs; a tab never hides troubleProposedthe Cluster page had grown to about ten panels, Settings → System to eight (node housekeeping mixed with how queries are handled), and Clients to three tables, one of them a router's whole client list (57 rows on the owner's network).

Context

the Cluster page had grown to about ten panels, Settings → System to eight (node housekeeping mixed with how queries are handled), and Clients to three tables, one of them a router's whole client list (57 rows on the owner's network). The owner asked for tabs as a quality-of-life change (2026-10-11).

Decision

  • One component (Tabs, TabPanel) for every tabbed page. The tab is in the address (?tab=), so links, reloads, and Back keep it. WAI-ARIA tabs: role=tablist/tab/tabpanel, the open tab alone in the Tab order, arrow keys, Home, and End. On a phone, the tab bar scrolls sideways.
  • A tab never hides trouble. A tab shows a count or a word, and in warning colours when something needs attention: failing checks and conflicts (Health), a version baking or a pin (Rollouts), failures in the last hour (Events). A pin stays loud on every tab (ADR-116): a note above the tabs links to the full banner. Health conditions link to the right tab (cluster_pinned and rollout_stuck go to #/cluster?tab=rollouts).
  • Pages:
    • Cluster: the summary stays above the tabs. The tabs are Nodes (topology, the node table, Maintenance…), Health (checks, conflicts, machines), Rollouts, and Events.
    • Settings: a new Query handling tab (Cache, Rate limit, Kept out of the log, Change simulation), apart from System (information, listener checks, version and updates, backup).
    • Clients: Seen this hour · Devices · Discovered, each with its size.
  • Only the open tab renders, so a closed tab loads and polls nothing new.
  • Pages of two or three short panels (Dashboard, Alerts, Upstreams, Lists, Local DNS, Cache, Rules) stay as they are. Groups (groups, schedules) is the next candidate once these have been used for a while.

Consequences

UI paths in the docs change ("Settings → Query handling → Rate limit"). Earlier ADRs and design notes keep the paths of their time. No API or MCP change.

Builds on ADR-116

ADR-122 The site's navigation by what the reader wants, and a Guides sectionProposedthe project site's header had grown to eight top-level links plus a Reference menu, one link per feature page, and it had no task guides.

Context

the project site's header had grown to eight top-level links plus a Reference menu, one link per feature page, and it had no task guides. How to set things up (configuration styles, clustering, Kubernetes, and more) lived only in docs/running.md, a 2,700-line file on GitHub (owner, 2026-10-11).

Decision

  • The header is Get started plus three menus: Features (what it does: Overview, Tour, How it works, Groups and VLANs, Monitoring and alerts, AI agents, Performance), Guides (how to do something), and Reference (unchanged). The logo goes to the Overview. One menu is open at a time; Escape or a click elsewhere closes it. Below 760px everything folds into ☰. It's built on <details>, so it works without JavaScript.
  • Guides start from a task, show each configuration style side by side where more than one works (web UI, config file, Git, API), and link into the configuration reference and docs/running.md for detail rather than copying it. The build's link check covers them.
  • Phase 1: a guides page and three guides: Choose how you manage settings (the four styles, how the layers stack, shared vs per-node settings, and where each setting lives), Build a cluster, and Run on Kubernetes. Phase 2 (2026-10-11): Upgrades, backups, and moving in; Block, allow, and explain; Groups, devices, and VLANs; Encrypted DNS and upstreams; Change things safely; Monitoring and alerts; and Users and sign-in. The Guides menu groups them under Set up and Day to day, and each guide links to the previous and next one. Feature pages link to their guide.

Consequences

guides are hand-written HTML and can drift from the code. Each one states only what the docs or the code say, and the docs stay the detailed source. Feature pages keep their URLs, so existing links still work.

ADR-123 The traffic map is a treemap of the existing top listsProposedOBSthe owner asked for a page that shows, at a glance and live, what people on the network are reaching, with allowed and blocked names in different colours, on a branch that could be dropped if it cost performance (2026-10-11).

Context

the owner asked for a page that shows, at a glance and live, what people on the network are reaching, with allowed and blocked names in different colours, on a branch that could be dropped if it cost performance (2026-10-11). A true heatmap (names × minutes) would need a count per name per minute, which nothing keeps today and which would add work to every query.

Decision

  • A Traffic map page (Monitor section) draws a squarified treemap of GET /api/v1/stats/top for domains and blocked (100 each, this hour or the last, every node). domains counts every query, so a name's answered queries are its domains count less its blocked count, and a remainder within the sketch's error bound is dropped. Tile area is queries; blue is allowed, red is blocked; the shade is the tile against the biggest of its colour (square root, so small tiles stay visible).
  • Grouping: Services (default) folds names into the blocked-services catalog's services (GET /api/v1/services, longest matching suffix) and falls back to the base domain; Sites uses the base domain (the last two labels, three under a country code's co/com/net/org/... level, a stand-in for the public suffix list the UI doesn't ship); Names shows them as asked. The choice is remembered in the browser.
  • Live: it refreshes every 5 s; counts growing since the previous refresh are the page's own differences (same hour only), and the five tiles growing fastest get a ring. Selecting a tile opens its names in the query log.
  • Nothing new on the server: no new counters, no new endpoint, one optional hour on the UI's existing top call.

Consequences

the map covers the busiest 100 names and 100 blocked names only, with Space-Saving's approximate counts; a new hour starts every tile again, so it looks sparse just after the hour. Background lookups (phones, apps, monitors) can dominate it; exclusions (OBS-022) keep them out. A per-minute heatmap stays possible later, behind a benchmark.

Requirements OBS-022

Referenced by ADR-129

ADR-124 SQLite never needs a temp fileProposedOPSthe image runs with a read-only root filesystem, no shell, and /var/lib/telltale as its only writable path (OPS-001, 08 §1).

Context

the image runs with a read-only root filesystem, no shell, and /var/lib/telltale as its only writable path (OPS-001, 08 §1). When a sort, a temporary table, or a VACUUM outgrows SQLite's memory budget, SQLite looks for a temp directory (/var/tmp, /usr/tmp, /tmp, then the working directory), finds none it can write, and fails the statement with "disk I/O error" (SQLITE_IOERR_GETTEMPPATH) without any file operation failing. On the homelab primary (2026-10-12) a cluster chart's shipped-minutes read did exactly that every 15 s once shipped_minute passed about 2 MB: ORDER BY start over a table keyed (node, start) was a full scan and a spilled sort, so cluster charts silently lost the minutes of nodes that weren't live.

Decision

  • Every connection to state.db and rollups.db, and the backup's copies, sets PRAGMA temp_store = MEMORY: temporary data stays in memory, whatever the filesystem allows.
  • shipped_minute gets an index on start, so the range read follows the index in time order: no sort, and it reads only the requested range instead of all 7 days.
  • Rejected: an emptyDir at /tmp in the chart. It would fix Kubernetes only (not Docker or Compose with a read-only root), and an unbounded disk-backed sort is worse than a bounded one in memory.

Consequences

a large sort or VACUUM uses memory instead of a temp file. These databases are small (state.db well under 1 MB, rollups.db a few MB), and the index removes the one sort that grew with the data. The index is created on start, in place, for existing databases.

Requirements OPS-001

ADR-125 Every node names a device the same way: device names are shared across the clusterProposedCLUeach node names the clients in its own query-log rows, anomaly findings, and identities, from the client catalog (replicated), then its routers' DHCP clients ([[router]]), then mDNS.

Context

each node names the clients in its own query-log rows, anomaly findings, and identities, from the client catalog (replicated), then its routers' DHCP clients ([[router]]), then mDNS. The last two are node-local. Usually only one node can read the router (the one with its API key mounted), and a pod can't hear the LAN's mDNS. On the homelab (owner report, 2026-10-12) the cluster query log named 192.168.5.2 "k8s" in the primary's rows and showed the bare address in the resolver pods' rows. The top lists hid the gap because their merge keeps any node's name.

Decision

  • Every node asks each reachable peer for the device names that peer learned itself (cluster call devices.names: its router and mDNS tables) once a minute. It keeps the merged answer as a fallback after its own sources. The order stays: a configured name, this node's router, this node's mDNS, the peers' routers, then the peers' mDNS. Among peers, the primary's answer wins, then node ID order.
  • A node answers with only what it learned itself, never what it got from peers, so a name a router forgot doesn't keep circulating. When no peer answers, the last table stays. A peer on an older build answers "unknown call" and is skipped.
  • As a backstop, the federated query log names any row a peer sent without a client name, using the node assembling the page. A row's own name always stays.
  • Rejected: mounting the router credentials in every pod. Each node would poll the router, the secret would spread, and mDNS would still be missing.

Consequences

a resolver pod's rows, findings, and device identities use the same names as the primary within a minute of start (or of a router change). The table is a few hundred entries a minute over the existing mTLS channel. Nothing touches the DNS path (CLU-004). GET /api/v1/dhcp/leases still lists only the node's own sources.

Requirements CLU-004

Referenced by ADR-150

ADR-126 Zone transfers are refused, never forwardedProposedOBSAXFR and IXFR had no special handling, so they were forwarded like any query.

Context

AXFR and IXFR had no special handling, so they were forwarded like any query. An upstream resolver can't serve a zone transfer, so each one came back SERVFAIL, was cached, and counted against the availability objective (OBS-016). On the homelab (2026-10-12) the old Technitium's catalog-zone sync (cluster-catalog.dnscluster, AXFR every 5 minutes via the Pi) was 288 of 359 SERVFAILs in a day and overspent the 30-day error budget. Each attempt also sent a private zone name to the public upstream.

Decision

  • AXFR (252) and IXFR (251) get REFUSED with EDE 21 (Not Supported, "zone transfers aren't served") from the early checks, after the allowed-networks check and the ANY answer. Status special. Nothing is forwarded or cached.
  • REFUSED, not NOTIMP: the opcode is implemented, and this server declines to serve the zone (RFC 5936 §2.2.1). REFUSED isn't SERVFAIL, so the availability objective doesn't count it.
  • Explain says the same.

Consequences

stray zone-transfer attempts cost one integer comparison and a short answer, and stop spending the error budget. TelltaleDNS serves no zones today. If it ever serves its local records as a zone, transfers would need a decision of their own (TSIG, an allow-list).

Requirements OBS-016

Referenced by ADR-136

ADR-127 Error-budget attribution: exact per-bucket maps, bad-only hourly top listsProposedOBST14.1 (OBS-027; design docs/design/error-budget-attribution.md).

Context

T14.1 (OBS-027; design docs/design/error-budget-attribution.md). The Service level card says how much budget is left, not who spent it; the owner needed a script to learn that one laptop's PTR name and the pre-10-13 AXFR SERVFAILs had (2026-10-16).

Decision

  • Exact where cardinality is bounded: bad answers per bucket by status, by upstream (owner 2026-10-16: exact, not a top list), and slow answers by response code; from the hour and day rollups for the whole window (400 days).
  • Approximate where it isn't: bad-only Space-Saving top lists per hour (32 entries, 20 stored) of names and clients, with the error bound shown.
  • Counted only on bad answers, on the telemetry thread; a bucket's maps are allocated with its first bad answer.
  • Owner decisions at task start (2026-10-16): slow_by_rcode added (the latency objective's rcode split); a resolver pod that isn't live counts from the primary's shipped minutes for 7 days and is listed as partial; the current hour (and the previous until stored) comes from memory; statuses are stored by label.
  • Rejected: the query log as the source (7 days on a Pi, the window is 30); per-name or per-client Prometheus series (cardinality).

Consequences

a few hundred bytes per bucket that saw a bad answer; client and name counts are upper bounds; older builds' hours have a timeline only.

Requirements OBS-027

ADR-128 Node comparison: robust statistics on the entry node, honest about small basesProposedOBST14.2 (OBS-028; design docs/design/node-comparison.md).

Context

T14.2 (OBS-028; design docs/design/node-comparison.md). The owner's cluster has two nodes of very different size (a Pi and the Kubernetes pods): a SERVFAIL share alone blames the smaller one for the same failures on a tenth of the traffic.

Decision

  • GET /api/v1/cluster/compare reads every non-witness node's buckets at once through the existing scoped timeseries read and computes on the entry node: no new RPC, no new storage, and older peers answer too. p50/p99 come from the latency histograms (OBS-030); a node with under 100 answers isn't compared on latency.
  • Three or more nodes: robust z against the other nodes' median and MAD (MAD floored at 0.0005 for ratios, 10 % of the median for latency), odd at |z| ≥ 3 when the node's own Wilson 95 % interval also leaves that median out. Two nodes: odd when the intervals don't overlap and the gap clears the same floored threshold, on the worse node only. A note, not odd, for about the same bad count (within 3, or a third) on under a third of the others' base.
  • oddSince is the change point of the node's cumulative excess over the others' pooled per-bucket rate (the latest minimum of the running sum), kept only when the node is still odd from there.
  • Defaults taken at the start (owner: accept all defaults, 2026-10-16): the leave-one-out median, the interval guard for three or more nodes, and the floor for two (without it the design's AC 2 first case would be odd, since its intervals don't overlap); the change point in place of "walk back until it stops being odd" (which lands on the window's start whenever the whole window is odd); 400 (not heatmap_window) for a window too long for its step; sparklines of at most 240 points; agents analytics:read, MCP compare_nodes; the Cluster page polls it every 60 s on every tab for the tab's "N odd". Not a health reason or alert rule (design §8) until the owner has watched it.

Consequences

one comparison costs one timeseries read per node (as many as the dashboard's node picker would); everything is rounded, so the same data gives byte-identical JSON. The verdicts are only as good as each node's buckets: a pod that restarted reads only its new minutes (its shipped minutes count for the whole cluster's view, not per node).

Requirements OBS-028 OBS-030

ADR-129 The rhythm heatmap reads the buckets and the hourly top lists; nothing new storedProposedOBST14.3 (OBS-029; design docs/design/rhythm-heatmap.md).

Context

T14.3 (OBS-029; design docs/design/rhythm-heatmap.md). The owner saw a 10 am–2 pm burst only with a script (2026-10-16). ADR-123 left a per-minute heatmap "possible later, behind a benchmark".

Decision

  • Three variants: the network (exact, from the time buckets), a device and a name (from each hour's top-100 list: exact while listed, covered: false where it wasn't). No new per-device or per-name storage.
  • Defaults taken at the start of the task (owner: accept all defaults, 2026-10-16): a device or a name has hour slots only (its lists are hourly), and a finer grain is 422 heatmap_window; 15-minute and minute slots reach back 7 days (the minute rollups' retention), not the design's 30 days for 15 minutes; a device is given by address; measure=blocked for the network and names only (a device's blocked queries aren't kept per hour); the current hour (and the previous one until stored) from memory; a name needs privacy level 0 and a device level 1 or lower (422 privacy_level); the dashboard thumbnail (design §8) is left out; the query log link opens from the cell's start (the log takes no end time).
  • The browser lays the slots out in local time; the API answers UTC starts.
  • The benchmark ADR-123 waited for: 7 days of minutes read and summed in under 150 ms on the build VM, and a minute-grain answer under 200 KB.

Consequences

devices and names outside the busiest 100 in an hour show as hatched, not zero; a new rollup kind would be needed for exact per-device grids.

Requirements OBS-029

Builds on ADR-123

ADR-130 The latency heatmap: per-minute bucket counts and an exemplar ringProposedOBST14.4 (OBS-030; design docs/design/latency-heatmap.md).

Context

T14.4 (OBS-030; design docs/design/latency-heatmap.md). Percentiles per hour hide a DoT cold-start tail or one slow PTR; the Prometheus histogram has the shape but the UI didn't draw it (owner, 2026-10-16).

Decision

  • The aggregator counts each answer into its BUCKETS_US bucket per path and per upstream, in minute buckets only (not the per-second ones), allocated with the minute's first answer; stored as section L; hours sum minutes.
  • The exemplar sink keeps its last 512 choices in a ring (at most one per path and bucket a second, as for OpenMetrics).
  • Defaults taken at the start (owner: accept all defaults, 2026-10-16): the real 16 bounds plus overflow (17 rows; the design's 14 was a miscount); the paths the histogram has (synthesized holds blocked answers); a federated read says partial (not which node) when some buckets lack the counts; the Upstreams page gets a card with an upstream picker (it has no drawer); no per-client heatmap (design §8: the device timeline T14.8 gets a latency lane); the aggregator's cost is measured with the counts switched off and on.

Consequences

about 300 B per minute bucket with answers (≈ 0.9 MB for 48 hours) plus 68 B per upstream used; one bucket index and an increment per event; exemplars are a live aid, older ones are gone.

Requirements OBS-030

ADR-131 The rhythm strip: a log-spaced gap histogram beside the beacon detectorProposedOBST14.5 (OBS-031; design docs/design/beacon-rhythm-strip.md).

Context

T14.5 (OBS-031; design docs/design/beacon-rhythm-strip.md). The beacon detector measured inter-arrival regularity but nobody could see it; a 2.5 s NTP loop (too fast to be a beacon) was invisible.

Decision

  • Each tracked (device, eTLD+1) pair counts this hour's gaps in milliseconds into 24 log-spaced bins (bin 0 under 1 s, then ratio ~1.5 to ≥ 7,290 s; saturating u16) and keeps a decayed copy folded at each hour's close (floor(previous / 2) + hour). The 16-gap buffer and the beacon rules are unchanged, so the golden fixture is byte-identical; the strip shows more than the detector judges.
  • GET /api/v1/clients/{id}/rhythm (federated Read::ClientRhythm, bins summed by domain), the Clients page's Rhythm drawer, the strip on beacon findings (AnomalyFinding.rhythm), MCP get_client_profile.rhythm.
  • Defaults taken at the start (owner: accept all defaults, 2026-10-16): no level-1 mode with domain: null (the engine is off from level 1), so the read answers 422 privacy_level from level 1; the beacon's snapshot kept beside the findings (Engine::beacon_rhythms, bounded like the findings) instead of inside Finding; the API's recent is the stored copy plus this hour; u16 bins (1.63 MiB at 1,024 devices × 16 pairs, under the design's 2 MiB line), so the per-device state bound becomes T3.13's 4 KiB plus 1,664 B (RHYTHM_BYTES); period in words from the 16-gap mean when it agrees with the dominant bin, shown at ≥ 50 %; the Clients page gains a Rhythm drawer (it had none); cells shaded by share of the row's busiest bin, ink --hm-ink; agents analytics:read. A "chatty periodic" finding (design §8) is left as a proposal.

Consequences

one partition_point over 23 bounds and one increment per query to a tracked pair, on the telemetry thread; anomaly.json grows by the same 1,664 B per device (older files load with empty bins).

Requirements OBS-031

ADR-132 Query flow: four exact joint counters, no proportional splitsProposedOBST14.6 (OBS-032; design docs/design/query-flow-sankey.md).

Context

T14.6 (OBS-032; design docs/design/query-flow-sankey.md). The buckets had marginals (by group, status, rcode) but no joints, so a flow diagram would have had to guess. The owner decided four stages (2026-10-16).

Decision

  • Each minute bucket counts group_status for every answer, and either status_upstream + upstream_rcode (an upstream on the event) or status_rcode (none): every link in the diagram is a counter incremented for exactly that pair.
  • Defaults taken at the start (owner: accept all defaults, 2026-10-16): status_rcode counts only the answers that asked no upstream (the design's AC had it count every answer, which can't then be split exactly for a status that sometimes asks an upstream); minute buckets only, as T14.4; a federated read says partial (not which node) for buckets without joints; a device from vqlog over the last 24 hours, whose own row cap sets truncated (in place of the design's 200k); a backend privacy-level read refuses client at level 2; the layout's invariants are checked by Playwright on the drawn SVG (the UI has no unit-test runner); group_status is kept in the rollups (the dashboard's traffic-by-group chart could read it later: a follow-up, unchanged here). Agents read it with analytics:read, a device's flow included (counts by group, decision, upstream and code, no names: like the device heatmap).

Consequences

a sorted array of at most ~100 keys per minute bucket with answers, two or three binary-search increments per event; no names involved, so nothing for the privacy levels to hide except the device mode.

Requirements OBS-032

ADR-133 Change markers: a bounded per-node annotation file, federated, with notesProposedOBST14.7 (OBS-033; design docs/design/change-markers.md).

Context

T14.7 (OBS-033; design docs/design/change-markers.md). Lining a change up with a graph meant reading timestamps across logs (owner, 2026-10-16); every later M14 chart overlays these markers.

Decision

  • Each node appends one JSON line per event to <data_dir>/annotations.jsonl from the background task that knows about it, never the query path or the telemetry thread; no fsync; 20,000 lines and 90 days; a failed write is logged once an hour and changes nothing else.
  • Owner decisions at task start (2026-10-16; details in the design, §9): seq is the cluster's sequence, else state.db's version; only the primary or a standalone node knows a change's source and actor (a replica writes gitops or cluster); config.applied only when configuration sections changed; the UI merges one kind and seq across nodes; lists.refreshed only where lists compile; notes need the operator role (agents ops:annotate); reads page back with before.
  • Notes: POST /api/v1/annotations, 1–200 characters, up to 7 days back, append-only (405), audited annotation.note, not configuration, not replicated.
  • Prometheus: new telltale_config_seq and telltale_annotations_total{kind}; Grafana annotations from changes().
  • Rejected: the audit table as the source (primary-only, misses restarts and breakers); Grafana's annotation API (credentials).

Consequences

at most a few MB per node; a replica's marker doesn't say who (the primary's does); markers don't outlive an ephemeral data directory.

Requirements OBS-033

Referenced by ADR-136

ADR-134 The device timeline: bounded vqlog reads folded into lanes, cachedProposedOBST14.8 (OBS-034; design docs/design/device-day-timeline.md).

Context

T14.8 (OBS-034; design docs/design/device-day-timeline.md). The owner found the 2.5 s NTP loop of 192.168.2.14 by hand; nothing showed one device's day with what happened around it.

Decision

  • GET /api/v1/clients/{id}/timeline runs three vqlog reads for the device (queries by registrable domain and status, slow answers by domain, every query with p99, per bin of whole minutes) and folds them into lanes (the top 30 by queries, other), a latency lane, and events: first-seen domains (OBS-009), the device's anomaly findings, and blocked bursts (≥ 10 blocked in a bin), with the change markers (OBS-033). Cached 30 s per request.
  • Defaults taken at the start (owner: accept all defaults, 2026-10-16): vqlog on the entry node (its log and the rows shipped to it), not a new Read::ClientTimeline; vqlog's bounds (2M rows, 100k groups, 25 s) in place of 500k rows and 5 s; the latency lane included; identity changes not yet events (they need changedAt); agents querylog:read; level 1 lanes without domain, level 2 422 privacy_level; id an address; the deep link #/clients?device=…&view=timeline with a device drawer (Rhythm, Timeline); a table of lanes × at most 24 columns; coverageFrom from retention_days; MCP device_timeline at 24 bins and 10 lanes.

Consequences

a request scans the device's rows three times (block-pruned by client), at most once per 30 s for the same view; the answer for a 7-day, 2,016-bin, 50-lane request is about 50 × 3 × 2,016 numbers.

Requirements OBS-009 OBS-033 OBS-034

ADR-135 The wall display: a kiosk layout over the existing readsProposedOBST14.9 (OBS-035; design docs/design/wall-display.md).

Context

T14.9 (OBS-035; design docs/design/wall-display.md). The pack's views, composed for a wall monitor; the screenshot that makes people try the project.

Decision

  • #/wall renders without the sidebar and header (signed in like any page), one panel at a time from ?panels= in its order, ?every= seconds each, with the live tail (/queries/stream) as a ticker of the newest 50. No new endpoints, metrics, or storage.
  • Motion: tile tweens (600 ms, by key), up to 60 particles on the flow, a sliding progress hairline; none of it with prefers-reduced-motion or ?motion=off; the animation loop stops while the tab is hidden.
  • Defaults taken at the start (owner: accept all defaults, 2026-10-16): wall-specific panels over the same layout modules (the map, flow, and heatmap are pages, not components) and the latency heatmap component with a wall prop; the map shows the busiest names only; a click anywhere exits; the SLO needle on a log scale to 14.4 (replaced 2026-10-17 by the burn rate in words, toned amber from 1× and red from 6×: the needle read as a minus sign beside the number); the helpers in ui/src/lib/wall.ts checked by the Playwright spec importing them (no unit runner); rotation checked with a real 5 s timer; the Pi CPU figure, the demo screenshot and GIF left to do; a Wall link on the dashboard in the Advanced view. A viewer role or kiosk session (design §8) is an auth change for its own ADR, proposed to the owner.
  • Owner request 2026-10-17: the Wall entry is an icon button in the header, between pause and the theme (Advanced view), not a link on the dashboard.

Consequences

the wall polls what the pages poll (top lists every 5 s, flow 10 s, SLO and latency 30 s, heatmap 5 min) plus one tail stream, so a wall costs about as much as a dashboard left open.

Requirements OBS-035

ADR-136 The site's decisions are an index plus pages of 50ProposedDOCdecisions.html rendered every ADR in full on one page and reached the 500 KiB page budget (DOC-005) with ADR-126; T14.7's ADR-133 took it to 502 KiB, and M14 adds eight more (owner chose to split it, 2026-10-16).

Context

decisions.html rendered every ADR in full on one page and reached the 500 KiB page budget (DOC-005) with ADR-126; T14.7's ADR-133 took it to 502 KiB, and M14 adds eight more (owner chose to split it, 2026-10-16).

Decision

  • decisions.html is the index: one row per ADR (number, title, status, areas, its first sentence), still searchable and filterable, linking to the record.
  • The records are on decisions-1.html (ADR-001 to 050), decisions-2.html (051 to 100), and so on (ADRS_PER_PAGE in site/build.py), with the same explorer within the page and links between the pages.
  • Links to a record go to its page (decisions-3.html#adr-133). The index keeps each row's id, so an old decisions.html#adr-NNN lands on that row without JavaScript, and with it goes on to the record.
  • Rejected: raising the budget for this one page (it would keep growing).

Consequences

the index is 80 KiB and each part about 200 KiB; search on the index covers titles and first sentences, full-text search works within a part page.

Requirements DOC-005

Builds on ADR-001 ADR-126 ADR-133

ADR-137 An expired SERVFAIL is never served staleProposedM14 follow-up (owner request 2026-10-17; found in T14.1).

Context

M14 follow-up (owner request 2026-10-17; found in T14.1). SERVFAIL is cached for 5 s (RFC 9520, spec/03 §4), but the serve-stale path treated an expired SERVFAIL like any expired answer: with stale_max_age = 3600 and stale_answer_client_timeout_ms = 0 (the owner's 2026-10-10 settings), an hourly reverse lookup that failed once got the same SERVFAIL back for up to an hour, counted as refreshed, even after the upstream would have answered. Serving a stale failure gives the client nothing RFC 8767 is for.

Decision

an expired SERVFAIL entry is a miss (removed, asked upstream again), and get_stale never returns one. Fresh SERVFAILs are still served for their 5 s. Every other answer keeps serve-stale as before.

Consequences

a failing name is asked upstream at most every 5 s per node instead of being answered stale for an hour; bad answers that were refreshed SERVFAILs move to forwarded (or go away). The availability SLO stops counting the same failure many times.

ADR-138 Chatty loops are an informational findingProposedOBST14.11 (OBS-036; T14.5's design §8).

Context

T14.11 (OBS-036; T14.5's design §8). The 2.5 s NTP loop that overspent nothing but puzzled the owner is too fast for the beacon detector (30 s to 1 h) by design. The owner chose an informational finding (2026-10-17) over counting it like other findings or adding none.

Decision

  • At each hour's close a tracked pair whose hour has ≥ 120 gaps with ≥ 80 % in one rhythm bin from 1 s to 25 s (bins 1–8) is a chatty_loop finding, on devices past learning, once per streak: an hour that isn't chatty resets it. The evidence: the hour's queries, the typical gap (the 16-gap mean, else the bin's middle), the bin's share, and the rhythm strip (kept like a beacon's).
  • Kind::informational() and AnomalyFinding.informational: the sidebar badge and the anomaly alert rule skip it; a new alert when = "chatty_loop" takes only these. Health never counted anomalies.
  • Defaults: bins 1–8 (bursts under 1 s are A+AAAA pairs, not loops; 25–38 s overlaps the beacon range); learned devices only, like every detector; no per-domain baseline (a loop is a loop whether it's usual or not).

Consequences

the golden fixture is unchanged (its devices have no such loop); telltale_anomalies_total{kind="chatty_loop"} counts them.

Requirements OBS-036

ADR-139 A device's last identity change is kept and drawn on its timelineProposedOBST14.12 (OBS-034, OBS-025; T14.8's deferral).

Context

T14.12 (OBS-034, OBS-025; T14.8's deferral). The timeline had no way to say "this is when it started looking like a Roku".

Decision

each identification pass compares a device's new identity with its last: a different signature (productId) or class is a change; a score or level wobble, a first guess, or an unavailable previous identity isn't. The last change's time and the previous product (else class) are kept per device in devices.json (changed_s, previous) and shown as DeviceIdentity.changedAt / previous; the device timeline adds an identity event when changedAt is in its window. Only the last change, not a history (defaults: one change per device keeps devices.json bounded; a history would be a new store).

Consequences

a device that flips between two guesses shows only the latest flip; in a cluster the merged (most confident) identity's change is the one drawn.

Requirements OBS-025 OBS-034

ADR-140 Wall displays: a display token exchanged for a wall-only sessionProposedOBST14.13 (OBS-035; T14.9's design §8).

Context

T14.13 (OBS-035; T14.9's design §8). A wall on an unattended screen needed a sign-in that lasts without leaving a person's account there. The owner chose a display token over longer viewer sessions or nothing (2026-10-17), and to keep the live tail on the wall.

Decision

  • A display token is a token row with the pseudo agent scope display (DISPLAY_SCOPE) and optional display_networks (CIDRs). Admins make, list, and revoke them (POST|GET /api/v1/displays, DELETE /api/v1/displays/{id}; Settings → Users → Wall displays); they replicate with the other tokens, so every node accepts one.
  • Its link is #/display?code=tt_<id>_<secret>: the code stays in the fragment (never in server logs). POST /api/v1/auth/display exchanges it for a session cookie marked with the token (sessions.display_token), from an allowed address; failures count toward the address's sign-in lockout.
  • A display session acts as viewer named display:<name>, with no idle limit, until the token's expiry (default 400 days) or revocation (the session ends with the token); its networks are checked on every request; it may only GET /auth/me, /system/info, /stats/*, /queries/stream, /annotations, and POST /auth/logout (display_allows). The code is never an API token. The UI shows only the wall to it. Making, opening, and revoking are audited (display.create, display.open, display.revoke).
  • Defaults: the pseudo agent scope (an older node in a mixed cluster reads it as an agent scope allowing nothing, so a display token can't act as a read token there); sessions survive the owner's password change (they belong to the token; disabling the owner ends them); the token's owner is the admin who made it (deleting that user deletes it).

Consequences

the wall's reads include the live tail (device and name), as for a viewer; a leaked link opens only that from the allowed networks until revoked.

Requirements OBS-035

ADR-141 Wall layouts for the screen's size and shapeProposedOBST14.15 (OBS-035; owner request 2026-10-17: "various sized screens ...

Context

T14.15 (OBS-035; owner request 2026-10-17: "various sized screens ... we will not be using the available real estate"). One panel at a time wasted ultrawide, portrait, and large screens.

Decision

  • Screen classes from the wall's size in CSS pixels (so a 4K TV at 2× scaling is 1920 × 1080): compact (shorter side < 600), portrait (height > 1.2 × width), large (≥ 2560 × 1300), ultrawide (width ≥ 2.1 × height), else standard; the previous class is kept while a 4 % change would still give it (no flipping at a boundary).
  • Per class, candidate templates (fractions of the panel area, the hero first, most slots first); chooseLayout takes the first one where every slot gets a panel at least its minimum size (PANEL_MIN): the hero the first panel that fits (the small-first panels saved), small slots (< 30 % of the area) the kpi and slo panels first; leftovers rotate in a slot they fit (the hero first). Pure and deterministic; tested across sizes for fit, overlap, fill, and placement.
  • Owner's choices (2026-10-17, "proceed with your recommendations"): a large screen shows everything at once (no rotation); a new kpi panel ("right now": queries per second, blocked share, from cache, the health word, from /stats/summary and /system/health); the tail as a strip except on a large screen, where it's a column.
  • ?layout=auto|single|grid|<class>; one animation loop for every panel (FrameHub), stopping in a hidden tab; panels size to their slot (the SLO rings, the flow's labels hidden under 600 px).
  • Default: display sessions may also read GET /api/v1/system/health (the tile's health word; what the header icon shows a viewer).

Consequences

a large screen polls every panel's data at once (as the dashboard would); the class comes from the whole wall while slots come from the panel area, so a large screen's tail column doesn't change its class.

Requirements OBS-035

ADR-142 Account changes need a signed-in sessionProposedT10.7 security review (finding A1).

Context

T10.7 security review (finding A1). Any credential (an API token, an agent token, HTTP Basic) could change its account's password and two-factor sign-in, and create or revoke the account's tokens. A leaked token could then take the account over, or mint tokens that outlive its own revocation. Decision (owner, 2026-10-17: "Signed-in sessions only"):

  • POST /auth/password, POST /auth/totp/{setup,enable,disable}, POST /tokens, DELETE /tokens/{id}, and POST /displays answer 403 unless the request comes from a signed-in session (cookie and CSRF token). Revoking a display (DELETE /displays/{id}) stays open to admin tokens: it only takes access away.
  • The current password in those requests is checked like a sign-in (A2): the bounded concurrent checks, failures counted, and the account locked after five.
  • A password changed or an account disabled on the primary ends that user's sessions on replicas too, and drops cached Basic results (A3).
  • A token made in two writes (an agent's or a display's) is deleted if the second write fails (A4).

Consequences

scripts that minted tokens with a token must sign in first (POST /auth/login, then the cookie and X-CSRF-Token); the shipped e2e scripts already did.

ADR-143 Cluster members are trusted by the registry, never by their wordProposedT10.7 security review (docs/security-review.md, findings C1, C3–C6).

Context

T10.7 security review (docs/security-review.md, findings C1, C3–C6). A member the registry didn't list (a resolver pod that left or expired, then reconnected with its still-valid certificate) was taken at its word: its Hello's eligibility was recorded, so it could get the cluster key, and its epoch could fence the primary. Pods also received every user's password hash and TOTP secret, and could ask other nodes to run simulations.

Decision

  • A member the registry doesn't know comes back as a plain ephemeral member: not eligible, not a witness, no key share, no epoch announcements, and it expires again when it stops being heard from. A cluster from before registries (taken at its word before) has to re-join such nodes; every release since v0.1 records members at join.
  • Vote requests are answered only for candidates the registry lists as eligible and not witnesses.
  • RPC answers count only on the stream of the peer that was asked (C2); request IDs start at a random value.
  • The users-and-tokens blob isn't served to ephemeral or unknown members, and pods ([cluster] ephemeral) don't fetch it and drop any copy an older version gave them. Pods serve no API (the chart's API Service selects only the controller), so nothing users do changes.
  • Peers the registry doesn't list are treated as ephemeral for forwarded writes, maintenance, rollouts, and simulations; ephemeral members can't run simulations, and a peer's simulation can't name a list file this node doesn't read, turn on link-local downloads, or exceed this node's window and limits.
  • The primary accepts at most 30 joins a minute (429 past that). A sender may have 4 unfinished shipped segments; a segment for an hour outside retention (or more than a day ahead) is acknowledged and dropped (an error would stall the sender); only registry members can ship.

Consequences

before rolling this out, each live node's registry must list every node (a read-only check), or the change could demote one. A pod scale-out of more than 30 in a minute takes a little longer.

ADR-144 Memory: purge freed memory at once, release code pages after one-off workProposedNFRT10.2 (NFR-001, 00 §5: ≤ 20 MiB idle, ≤ 64 MiB with 1M names and 100k cache entries).

Context

T10.2 (NFR-001, 00 §5: ≤ 20 MiB idle, ≤ 64 MiB with 1M names and 100k cache entries). With the shipped musl build the node measured 22.7 MiB idle and 71.4 MiB loaded. Two things held memory nothing used: mimalloc's 10 ms purge delay kept a compile's freed pages (a 170 MiB peak, about 5 MiB after), and about 15 MiB of the 25 MiB of code stayed resident after running once at start-up, plus the kernel's fault-around neighbours.

Decision

  • mimalloc's purge_delay is set to 0 at the start of run: freed pages go back to the OS right away. The query path doesn't allocate (NFR-002), so the extra madvise calls land on misses, compiles, and background work.
  • 15 s after start-up, and after each filter index build, a background thread drops this process's mappings of the executable's read-only pages that hold no private (copy-on-write) copies (MADV_DONTNEED). The pages stay in the page cache; code that runs again maps them back with a minor fault. Writable mappings and any with anonymous pages are never touched.
  • Both live in telltale-net (the only crate allowed unsafe), using the libmimalloc-sys already linked (no new crate).

Consequences

idle 13.7 MiB, loaded 60.0 MiB, compile peak 97.5 MiB (shipped build, bench/memprobe.py). The first query after a trim may take a few minor faults (microseconds each) for code that was dropped. RSS creeps back up by the code periodic work touches (about 1 MiB over 3 minutes in the probe). Off Linux both are no-ops.

Requirements NFR-001 NFR-002

ADR-145 A recompiled filter goes live before its FSTs are writtenProposedFLTT10.3 (FLT-003, 00 §5: a 2M-name recompile within 8 s on a Pi 4).

Context

T10.3 (FLT-003, 00 §5: a 2M-name recompile within 8 s on a Pi 4). On the Pi the one-thread recompile took 11.8 s, 8.1 s of it building the domain FSTs (the fst crate's builder, about 4 µs a name on one core); two threads cost more p99 during the compile, so the owner kept one thread (2026-10-17) and chose this change. The FSTs are the snapshot's on-disk form (cold start, replication, the walk fallback); queries are answered from the hash index (ADR-020), which load used to build from the FSTs after the compile. Decision (owner, 2026-10-17: "I'll follow your recommendation"):

  • The compile merges the sorted records twice. The first pass computes each name's list set as before and fills the lookup index directly; with the modifier and regex tables (written first) it makes a matcher over a pending snapshot (no FSTs), which the server serves at once. The second pass writes the FSTs and listsets.bin exactly as before, and the snapshot directory is finished as before.
  • When the finished snapshot is loaded, it reuses the pending matcher's index (same version, same list-set table) instead of building another; the FST pages are released at once.
  • The 8 s gate is measured to when the recompiled filter answers (CompileTimings::ready); the total compile is reported too.
  • If the second pass fails (a full disk), the previous filter is put back, so a node never serves a filter it has no snapshot of.
  • Not for: a pinned cluster (the pin is served), or a snapshot past the index's 65,536 list sets (it waits for the FSTs and uses the walk, as before). Replicas still get the finished snapshot, after the second pass, as before.

Consequences

a list change answers about 40 % sooner on the Pi; the compile does a second merge pass after that (more CPU in all, at SCHED_IDLE), and holds the index while writing the FSTs (the compile's peak grows by the index, about 11 bytes a name). The index is sized from the record count, not the distinct names (a few percent larger when lists overlap). While pending, Snapshot::domain_hits (a test and explain helper over the FSTs) returns nothing; explain itself goes through the index.

Requirements FLT-003

Builds on ADR-020

ADR-146 Supply chain: auditable binaries, SBOMs from them, keyless image signatures, reproducible buildsProposedNFRT10.10 (NFR-005: cargo-deny, cargo-auditable, a CycloneDX SBOM, cosign-signed images, reproducible builds).

Context

T10.10 (NFR-005: cargo-deny, cargo-auditable, a CycloneDX SBOM, cosign-signed images, reproducible builds). cargo-deny already ran in CI and the binaries were already minisign-signed (ADR-046); the rest was missing. Two clean builds of the same tree differed: C code linked into the binary (mimalloc) embeds __TIME__, and the build date stamped into --version was the time of the build.

Decision

  • Release binaries are built with cargo auditable (pinned, checksum-verified like the other build tools): a compressed dependency list in a .dep-v0 section, which CI checks is present in every binary.
  • The SBOM is read from the binary, not from Cargo.lock: syft (pinned) writes one CycloneDX JSON per binary (telltale-<arch>-linux.cdx.json), checked to list the crates it should, published with the release, and covered by the minisign-signed SHA256SUMS.
  • Images are signed by digest with cosign, keyless (the image workflow's GitHub OIDC identity; Fulcio certificate, Rekor log), on every push to edge and every release tag; the workflow verifies the signature it made, and deploy/release/verify.sh checks it for stable releases. No signing key to keep.
  • Builds are reproducible from the commit: the build date is the commit's time, and SOURCE_DATE_EPOCH (the commit's Unix time) is passed to the Docker build, so compilers take __DATE__/__TIME__ from it and BuildKit stamps layers with it. A nightly job builds the amd64 release binary twice without cache and fails if they differ.

Consequences

--version's "built" date is the commit's date. Reproducing a published binary needs the same build arguments the workflow passes (all derived from the commit and the run: version, commit, date, channel, SOURCE_DATE_EPOCH). The image job needs id-token: write. The nightly reproducibility check costs two uncached builds (about 20 minutes each on a runner).

Requirements NFR-005

Builds on ADR-046

ADR-147 A list that shrank a lot waits for the same content againProposedFLTFLT-004 keeps a list's last good copy when a download fails, and refuses bodies that can't be a list (empty, HTML, mostly invalid lines, over the size cap).

Context

FLT-004 keeps a list's last good copy when a download fails, and refuses bodies that can't be a list (empty, HTML, mostly invalid lines, over the size cap). A body that is a list but much shorter passed every check: in the live review of 2026-10-14, hagezi-tif went from 2.55M to 1.53M lines over ten days, unnoticed. A truncated mirror or a source caught mid-rewrite would have dropped blocking the same way. The owner chose to add a guard (2026-10-18).

Decision

  • [filter] max_shrink_percent (default 50, 0–100; 100 = off). A URL list's download that differs from the stored copy and has lost more than that share of its lines is held: not stored, not compiled; the stored copy, its validators, and its schedule stay (a held download isn't a failure, so there's no early retry); its hash is kept in the list's metadata and the reason is the list's error on the Lists page and in the log.
  • The next download with the same hash is accepted (the next scheduled refresh, or a manual one: "Refresh now" is the way to accept a shrink at once). Different content is judged afresh against the stored copy.
  • Not held: stored copies under 1,000 lines (their size swings with a few edits), path and inline lists (they shrink because someone edited them), and a changed source URL.

Consequences

a list that genuinely shrinks a lot takes one refresh interval longer to apply (the stored, larger copy keeps blocking meanwhile, the safe direction). A source that keeps serving different truncated bodies is never accepted while each one stays below the threshold, which is the point. Gradual shrinking (like tif's, a few percent a day) isn't caught; the Lists page's line counts remain the way to notice that.

Requirements FLT-004

ADR-148 Stream upstream connections: handshakes outlive their query, silent connections are closed, every connection is countedProposedUPSthe live review of 2026-10-14 (item 5): about 2 % of DoT attempts to Quad9 failed (network errors, about twice the timeouts), hidden by retries, and forwarded answers took 51 ms at p90 with a 10 ms round trip.

Context

the live review of 2026-10-14 (item 5): about 2 % of DoT attempts to Quad9 failed (network errors, about twice the timeouts), hidden by retries, and forwarded answers took 51 ms at p90 with a 10 ms round trip. UPS-008 asks for pooling with an idle timeout, and spec/04 §5 lists the pool size and TLS handshake count among the exported metrics, which didn't exist; nothing showed how often connections opened or why they ended. Two gaps were found in the pool: a new connection was opened inside the query's attempt timeout (about 3 × the EWMA + 50 ms, often under 100 ms), so a handshake slower than that was abandoned with the query and the next query started another; and a connection dropped silently along the way (no FIN, no reset) stayed the least-loaded one in the pool, so every query on it timed out until TCP gave up, possibly for minutes. The owner asked for the item to be fixed (2026-10-18).

Decision

  • A pool opens a connection in a task of its own: the query's timeout still applies to the query, but the handshake finishes and the connection joins the pool for the next one.
  • A pooled connection with a query waiting 3 s or more, and nothing at all received in those 3 s, is closed (unanswered); queries still waiting on it get a closed channel and retry once on a new connection (the T10.9 retry). 3 s is longer than any attempt timeout (at most 1 s), so a single slow answer on a quiet connection isn't mistaken for a dead connection. Waiters that gave up are dropped 3 s after their query went out, so a connection can idle out again.
  • New metrics per stream upstream (TCP, DoT, DoH, and UDP's TCP fallback): connections opened (ok/failed), the time spent opening them, TLS handshakes full/resumed, pooled connections closed by reason (idle, server, write_failed, unanswered, unused), retries on a fresh connection, and open pooled connections.
  • Not done yet: RFC 7828 (edns-tcp-keepalive, to learn the server's idle timeout) and keeping a connection warm with periodic queries. The new metrics show whether servers close idle connections first (reason="server") and how often a quiet upstream reconnects, which decides whether either is worth it.

Consequences

a silently dropped connection costs its queries at most about 3 s once, instead of a timeout per query until TCP gives up; a slow handshake is paid once, not once per query. Connection churn and session resumption can be read from metrics. Each connection with queries waiting wakes every 500 ms to check; an idle one doesn't.

Requirements UPS-008

ADR-149 HTTPS on the API listener: your certificate or a self-signed one, plain HTTP redirectedProposedAPIOBSAPI-003 wants TLS for anything carrying passwords; ADR-029 left TLS on the API listener for later (the session cookie's Secure flag and HTTP Basic depend on HTTPS, which until now only a TLS proxy in front could give).

Context

API-003 wants TLS for anything carrying passwords; ADR-029 left TLS on the API listener for later (the session cookie's Secure flag and HTTP Basic depend on HTTPS, which until now only a TLS proxy in front could give). The live review of 2026-10-14 (item 8) found the Pi's UI on plain HTTP: sign-in form passwords cross the LAN in the clear. The owner chose a self-signed certificate made on first start for the Pi, and a redirect for plain HTTP on the same port (2026-10-18).

Decision

  • [api] tls = { self_signed = true } makes an ECDSA P-256 certificate (every browser accepts the algorithm) on first start, for localhost, 127.0.0.1, ::1, the host's name and <name>.local, the listen address, and the addresses the host reaches the network from; kept in <data_dir>/api-tls/ (the key readable by the user only) and reused. It never expires (rcgen's defaults); deleting the directory makes a new one. { cert, key } uses the operator's PEM files, re-read within 10 s like the DNS listeners' (CertStore).
  • The listener tells TLS from plain HTTP by a connection's first byte (0x16, a TLS handshake record). Plain requests get 307 to the same URL over https:// (temporary, so turning TLS off again leaves no cached redirect), except /livez, /readyz, and /healthz, which keep answering plain health checks (telltale health, the Compose healthcheck). HTTP/1.1 only, as before.
  • Requests over the listener's TLS carry a marker extension (TlsConnection) that is_https honours from any peer, so the session cookie is Secure and HTTP Basic works, exactly as behind a trusted TLS proxy.
  • HSTS (max-age=31536000) only with the operator's own certificate: with HSTS a browser offers no way past a certificate warning, and a self-signed certificate is one until it's trusted.
  • If the certificate can't be made or loaded, the API stays off (logged) rather than serving plain HTTP; DNS keeps answering. Changing [api] tls needs a restart (the API listener starts once), and a reload says so.
  • telltale ctl and telltale mcp --stdio use https:// when the config has [api] tls, trust the node's own self-signed certificate, and take --ca-file for another node's.
  • The listener checks (OBS-020) include the web UI (proto = "api"): a GET /livez over HTTPS, and its certificate's expiry, SHA-256 fingerprint, and names, with the same health reasons, alert rule, and metric as the DNS listeners' certificates. It never counts toward not_serving (that's about DNS). Every listener's certificate now shows its fingerprint and names too.

Consequences

sign-ins on a home network are encrypted with no setup beyond one line, at the cost of a browser warning (or importing the certificate once). The Helm chart is unchanged: in Kubernetes an ingress terminates TLS. The cookie's Secure flag stays conditional (ADR-029): plain HTTP without [api] tls still works.

Requirements API-003 OBS-020

Builds on ADR-029

ADR-150 Kubernetes workloads as devices: a read-only pod watcher on the controller, a synthetic analytics key, groups from labelsProposedCLUFLTOBSFLT-017 (owner request 2026-10-18).

Context

FLT-017 (owner request 2026-10-18). On the homelab k3s node, pods are clients like any other, but a pod address names nothing and changes on every rollout, so a Deployment's three replicas look like three short-lived devices and per-device analytics (baselines, rhythm, timeline, new-device status) restart each time. Nothing in the code talks to the Kubernetes API today, and the chart mounts no service-account token (ADR-120 keeps secrets off resolver pods). Design: docs/design/kubernetes-workloads.md.

Decision

  • A workload is (namespace, kind, name), kind deploy/sts/ds/job/cronjob/pod (rs when a ReplicaSet lacks pod-template-hash), derived from the pod's ownerReferences alone; a Job whose name ends in -<digits> is a CronJob run (suffix stripped). Text form ns/kind/name. Host-network pods are skipped.
  • Watcher: only the process that has a service-account token (the controller in scaled/daemonSet, the pod in allInOne) lists and watches pods and namespaces with the existing rustls HTTP client and a minimal serde struct: no Kubernetes client crate. Token re-read every 5 minutes; 410 Gone or any error re-lists after a jittered 1–60 s backoff; a full re-list every 10 minutes. A node without the in-cluster environment logs one info line and does nothing.
  • Table: Workloads in telltale-policy, an ArcSwap rebuilt (debounced 500 ms) on change, capped at 20,000 pod addresses. Node-local operational state like the neighbor table: no configuration version, no snapshot replication. The controller pushes it to peers on change (peer call workloads.table, debounced 2 s) and peers pull every 60 s; older peers answer "unknown call" and are skipped.
  • Query path: ClientTable::identify consults the table after client ID, EDNS MAC and neighbor MAC, before IP/CIDR and the network group, only when the table is non-empty; a hit is IdSource::Workload. A workload wins over a [[client]] keyed by a pod address (stale after a rollout).
  • Groups: pod label telltaledns.io/group > namespace label > first [[kubernetes.map]] rule (namespace glob, optional kind) > [kubernetes] default_group > the network group or default. The table stores group names; indices are resolved against the current ClientTable whenever either changes, so a configuration reload can't leave a stale index. An unknown group from a label is logged once per value and treated as unset; an unknown group in [[kubernetes.map]] is a validation error.
  • Analytics key: the aggregator substitutes a synthetic address for the pod address, for analytics only, where an event is drained: the RFC 6666 discard prefix 100::/64 followed by the first 8 bytes of BLAKE3(ns/kind/name). (The design note said 12 hash bytes after 100::; that would leave the discard prefix, so the prefix keeps all 64 bits.) The query log keeps the real pod address.
  • Names at read time (extends ADR-125): pod addresses carry up to 8 (workload, from, to) intervals over 30 days, persisted to <data_dir>/workloads.json hourly and at shutdown; the query log, timeline and live tail name a row by the workload that held its address at the row's time. A deleted pod's address stays mapped for 30 s (in-flight queries), then moves to history.
  • Chart: kubernetes.enabled (default false) mounts the token on the controller/allInOne pod only, creates a ClusterRole with get/list/watch on pods and namespaces and nothing else, and writes [[forward]] entries (cluster domain and optional pod/service reverse zones, with a DNSSEC NTA) to kubernetes.clusterDns, which the schema requires when enabled. service.dns.clusterIP fixes the DNS Service address for the kubelet's cluster DNS.

Consequences

  • One ArcSwap load and hash lookup per query when the table is non-empty, none otherwise; one lookup per event on the telemetry thread.
  • The API server being unreachable leaves the last good table in place; kubernetes_stale makes health degraded after 15 minutes, and telltale_kubernetes_* metrics count errors. Answering is never affected (CLU-004).
  • Pods are only seen as themselves when they query TelltaleDNS directly (cluster DNS pointed at it, or per-workload dnsConfig); through CoreDNS forwarding they all appear as CoreDNS.
  • A CronJob's runs are one device; the run name is evidence.
  • Non-goals: answering cluster.local, namespace egress allowlists, more than one Kubernetes API per controller.

Amendments at implementation (T15.1, 2026-10-19):

  • Another controller owns the pod (an operator's custom resource; the design named only the built-in kinds): that owner is the workload, kind = its kind in lower case, so its pods stay one device across restarts. A map rule can't name such a kind (the validated list is the built-in ones); a namespace rule or a label covers it.
  • A pod first seen terminating (or finished) is never added: it has no queries of its own to attribute. One seen running and then deleted keeps its addresses for the 30 s grace period.
  • The chart's forwarding is TOML the chart appends to the values file ([[upstream]] kubernetes-dns, [[upstream_group]] kubernetes, a [[route]] with a DNSSEC NTA), not a separate file: layered files replace arrays, so a [[upstream]] in config would otherwise drop it. Reverse zones are the pod and service CIDRs rounded down to whole octets (IPv4 only). Kubernetes options go through the chart's values, not a [kubernetes] table in config.
  • Per-workload counts (queries and blocks over 24 hours, last seen) are kept per node on the telemetry thread, only for events whose client is a workload key (at most 20,000 workloads), and summed across nodes for GET /api/v1/kubernetes/workloads (Read::WorkloadCounts; a node that doesn't answer is in missingNodes).
  • Time-aware filters: client=<workload> on the query log, the vqlog client condition, and the device timeline match a pod address only while that workload held it (from the history). The query log searches the addresses (at most 64, clientTruncated) and then drops rows outside the workload's stretches, so a page can hold fewer rows than limit.
  • API-made map rules come first, before the files' rules (first match wins), so "Add to group…" on one workload wins over a namespace glob in the files; they replace or hide a file rule with the same key (namespace/kind/name, * for any).
  • Simulation replays with an empty workload table: a pod address takes the group its row was logged with (what the simulator already does for devices recognized by MAC), so changing a map rule isn't simulated.
  • Extra metrics: telltale_kubernetes_watching (1 on the node that watches, which the alert needs) and telltale_kubernetes_addresses_dropped.
  • Rebuild debounce 50 ms, not 500 ms: a Job's pod asks DNS within about a second of getting its address; at 500 ms its first queries counted under the address (seen on a kind cluster: a CronJob at zero queries). A rebuild of hundreds of pods costs well under a millisecond on the watcher's thread. A pod's first queries can still beat the table; the query log names them by the workload anyway (the address's first stretch starts at the pod's creation).
  • The analytics wait up to 2 s for an unknown address: even at 50 ms, a CronJob run's queries (about a second after its pod is created) beat the watch event that carries its address, so each run counted as a new device under its bare address and the workload showed zero queries. While Kubernetes is on (from the node's start, before the first table arrives), the aggregator holds a query from an address the table doesn't name for up to 2 s (at most 8,192 records, about 2.5 MB), then counts it keyed by the table it finds. A table change lets everything out at once, so each address's records stay in order. The query log, the live tail and the exports are never held; only the aggregates and the anomaly engine wait. The cost on a node that also serves a LAN: its devices' dashboard numbers lag by up to 2 s while Kubernetes is enabled.
  • A restart of TelltaleDNS keeps the workload analytics: the startup replay of the query log (OBS-004) keys each row by the saved history (the workload that held the address at the row's time), so the restored hours list workloads, not pod addresses. Each workload's 24-hour counts are saved hourly and at shutdown in workload-counts.json, beside workloads.json but separate, because counts are per node and the table travels to peers. They are folded back in at start. A crash loses at most the last hour's counts.
  • A map rule applies at once: a configuration reload wakes the watcher, which rebuilds the table if [kubernetes] or the group names changed. Before, it waited for its next one-second tick, and a query right after "Add to group…" could still get the old group (found by the Playwright test). Other nodes get the rebuilt table by push, within a few seconds; the preview says so.
  • Table memory: the first build took 544 KB of heap for 1,000 pods (AC 8 asks under 300 KB), mostly per-address history vectors with spare capacity, a sparse hash map, and a copy of the workload name per interval. The history is now a sorted array of exact-size lists, searched by binary search because it's never read on the query path. Names are shared strings, one per workload. Measured: 288 KB (flt_017_table_and_history_for_1000_pods_under_300_kb).
  • Test hook: TELLTALE_KUBERNETES_FIXTURE=<file> makes a node watch a JSON file of pods and namespaces instead of an API server (the cluster e2e, the UI tests, demos).

Requirements CLU-004 FLT-017 OBS-004

Builds on ADR-120 ADR-125