For the technical audience
Decisions ADR-001 to ADR-050
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-001 Language: RustPerformance and footprint are paramount: a small, steady memory footprint on a Raspberry Pi and microsecond cache hits.
Context
Performance and footprint are paramount: a small, steady memory footprint on a Raspberry Pi and microsecond cache hits. That rules out garbage-collection pauses and runtime memory overhead, and we want memory safety at the same time.
Decision
Rust (stable, 2024 edition), tokio, rustls (ring or aws-lc-rs backend), quinn.
Consequences
No GC, static musl binaries, memory safety, and a mature DNS crate ecosystem (hickory). Compile times are slower and the contributor pool is smaller. unsafe is confined to telltale-net.
Referenced by ADR-136
ADR-002 Custom hot-path wire handling; hickory for the long tailFull decode/encode per query costs allocations and µs.
Context
Full decode/encode per query costs allocations and µs. Cache hits only need the header, question, and TTL offsets.
Decision
telltale-proto handles the hot path zero-copy. hickory-proto is used for DNSSEC, zone files, and complex record handling.
Consequences
Two code paths, so cross-checking them against each other is part of fuzzing (differential fuzz: both parsers must agree on header, question, and EDNS).
ADR-003 FST + regex DFA snapshots, compiled off-pathPi-hole keeps lists in SQLite with a verdict cache, and Technitium keeps them as in-memory zones; both suit their designs.
Context
Pi-hole keeps lists in SQLite with a verdict cache, and Technitium keeps them as in-memory zones; both suit their designs. TelltaleDNS's goals (multi-million-name lists on a Pi, lookup cost independent of list size, no pause during updates) call for a compiled index.
Decision
Immutable, mmappable FST snapshots (reversed-label keys) + a multi-pattern lazy DFA, built in the background and swapped atomically.
Consequences
~10× less memory per domain, O(|qname|) lookups, and instant reloads. Snapshots are immutable, so every edit triggers a recompile. To keep manual rule edits instant, a small overlay (a HashMap of manual rules) is consulted before the FST and folded into the next compile. Amendment (2026-10-07, review 02-07; owner's answer: remove): the overlay was never used. Quick rules (ADR-067, telltale-policy::quick) are what make manual rule edits instant: they decide before the lists, and the matcher no longer has an overlay (no code constructed one outside tests). Rule edits that belong in a list are list edits, which recompile in the background. The paragraph above is kept as the record of what was first decided.
Builds on ADR-067
ADR-004 Container-first distribution, Kubernetes-first operationsThe owner prioritizes Kubernetes and also runs a Raspberry Pi.
Context
The owner prioritizes Kubernetes and also runs a Raspberry Pi. Native packaging and installers per distro are costly to build and keep working.
Decision
Tier-1 artifacts are the multi-arch OCI image + Helm chart, and the same image runs on the Pi with host networking. The static binary + systemd unit is Tier 2.
Consequences
One artifact to test. The Pi needs Docker/Podman (~50 MB extra), which is acceptable on Pi 3+. A Pi Zero can use the native binary.
ADR-005 Clustering: fenced primary/replica with optional witness, not Raft-by-defaultThe reference topology has exactly two nodes (Pi + k8s).
Context
The reference topology has exactly two nodes (Pi + k8s). Raft needs 3 voters to tolerate one failure. DNS must never depend on consensus.
Decision
A replicated, signed change log from a single primary. Epoch-based fencing. Promotion is manual (2 nodes), witness-assisted, or quorum (≥ 3). Data-plane independence (CLU-004).
Consequences
- Config writes pause during a primary outage in manual mode (DNS is unaffected).
- Orphaned writes are possible and surfaced, never silently merged.
- Much simpler than embedding openraft, and correct for 2 nodes.
- Revisit (ADR-0NN) if multi-writer is ever needed.
Requirements CLU-004
ADR-006 Telemetry: custom columnar segments + SQLite rollupsPi-hole keeps long-term history in SQLite, and Technitium hands per-query logs to database apps.
Context
Pi-hole keeps long-term history in SQLite, and Technitium hands per-query logs to database apps. TelltaleDNS wants 30-day searches in seconds on a Pi with no external database, and DuckDB/ClickHouse are too large for a Pi.
Decision
Hourly columnar segments with dictionary encoding, a block index + bloom filters, and zstd, plus SQLite rollups. Parquet export for external analysis.
Consequences
We own a storage format (versioned, fuzzed, with a migration tool). Search is fast via dictionary-first predicate evaluation. External SQL access goes through export or the API, not live SQL.
ADR-007 Plugins out-of-process (socket/exec) first, WASM laterThe owner needs custom upstreams.
Context
The owner needs custom upstreams. A plugin loaded into the resolver's process would share its memory and its fate: a crash or a leak there takes DNS down.
Decision
DNS-wire-over-socket plugins for upstreams in v1, with WASM (wasmtime, feature-gated) in v2.
Consequences
Plugin crashes can't take down DNS, there is a small IPC latency cost (~20–50 µs), and plugins can be written in any language.
Referenced by ADR-084
ADR-008 Auth: local users + OIDC; no LDAPThe owner requires basic auth and OIDC, and explicitly skips LDAP.
Context
The owner requires basic auth and OIDC, and explicitly skips LDAP.
Decision
Local Argon2id users (session login + opt-in HTTP Basic for API/scrapers + tokens + TOTP) and OIDC (PKCE) with claim → role mapping, both P0.
Consequences
Smaller attack surface. LDAP users bridge through an OIDC IdP.
ADR-009 UI: Svelte + uPlot embedded
Decision
A small SPA compiled to static assets and embedded via rust-embed. No Node runtime in the product.
Consequences
One binary. The UI build is part of CI (Node only at build time).
Referenced by ADR-030
ADR-010 Agents as first-class clients via built-in MCPThe owner expects agents to manage the platform and run analytics.
Context
The owner expects agents to manage the platform and run analytics.
Decision
- MCP tools are thin wrappers over the REST service layer, so the tool and API schemas come from one source.
- Agents get dedicated, scoped, read-only-by-default tokens.
- Writes use plan/apply with optional human approval.
- Every node exposes
/mcp, so agents get the single management plane too.
Consequences
No separate logic to keep in sync. Agent writes are always previewable and attributable. The plan store adds a small table replicated with config.
Referenced by ADR-065
ADR-011 Project name and identifiers: TelltaleDNS"Vigil" was a working name and collides with an existing Rust project (a status-page monitor).
Context
"Vigil" was a working name and collides with an existing Rust project (a status-page monitor). The owner chose TelltaleDNS on 2026-10-02. A telltale shows what the wind is doing; DNS shows what the network is doing, which matches the observability-first mission.
Decision
The brand is TelltaleDNS. Machine identifiers use the short form telltale:
- binary/CLI
telltale, crate prefixtelltale-*, env varsTELLTALE_* - paths
/var/lib/telltaleand/etc/telltale, config filetelltale.toml - Helm chart and image
telltale, metrics prefixtelltale_, backup archives.ttbk
Workspace crates set publish = false, so the short prefix can't collide on crates.io.
Consequences
Short, typeable identifiers. If crates are ever published or the registry name is taken, switch the published names to telltaledns-* without renaming internal crates. Owner to confirm: short telltale form vs. telltaledns everywhere.
ADR-012 Project site: hand-written static HTML + inline SVG on GitHub PagesThe owner wants a GitHub Pages site for two audiences (non-technical and deeply technical), visual-first, including a standards page with diagrams per RFC (DOC-001..006).
Context
The owner wants a GitHub Pages site for two audiences (non-technical and deeply technical), visual-first, including a standards page with diagrams per RFC (DOC-001..006). The project values a lightweight footprint and minimal tooling.
Decision
- A
site/directory of plain HTML pages, one shared CSS file, and inline SVG diagrams (hand-authored, or exported from Mermaid/Excalidraw sources committed beside them). No site generator and no Node build. - A tiny optional JS file only for the theme toggle and tabs; every page works without it.
- The Standards page table is generated from
site/data/standards.jsonat deploy time by a small script in the Pages workflow, which also fails if a ticked roadmap task cites an RFC missing from the file. - Deployed by a GitHub Actions Pages workflow on push to
main.
Consequences
Zero build dependencies and fast pages. The shared header/footer is duplicated across pages (acceptable at ~10 pages; revisit with a minimal generator past ~20). Pages must be enabled once in the repo settings (source: GitHub Actions).
Addendum (2026-10-05, T6.10, Proposed):
- Navigation: the header keeps five main pages. The reference pages (Configuration, Glossary, Standards, For nerds) go in one "Reference" menu. GitHub and the theme are icon buttons. Below 860 px the nav folds into a ☰ menu. The menu is built on
<details>, so it works without JS;site.jsonly closes the ☰ menu on narrow screens and the Reference menu on Escape or an outside click. This replaces the wrapping row of nine links. - For nerds page (
nerds.html): generated fromsite/data/architecture.json. The build checks it against the repo and fails on any disagreement, and CI runs that check (site/build.py --check). The checks:- every crate under
crates/is described, and nothing else is; - the listed public types exist in their crate;
- ADR and requirement IDs exist in
spec/11,spec/01andspec/13; codepaths exist;- stack entries name real dependencies.
- every crate under
- Read from the repo, not the JSON:
- dependency arrows ("uses" and "used by") from the crates'
Cargo.tomlfiles; - line counts;
- the budgets, from
spec/00§5.
- dependency arrows ("uses" and "used by") from the crates'
- Interaction: the map's crates are plain links to their descriptions further down.
assets/nerds.jsadds the side panel and edge highlighting. - Pages deploys: they now also run on
crates/**,spec/**andCargo.tomlchanges, so the line counts and arrows stay current. - Contrast: small accent-coloured text (section eyebrows) uses
--accent-text(5.2:1) in the light theme.
Requirements DOC-001
ADR-013 Hedged upstream attempts and a faster breaker tripspec/04 §4–5 retries the next upstream only after an attempt fails or times out (up to 400 ms), and opens a breaker only after ≥ 10 samples with > 50% errors.
Context
spec/04 §4–5 retries the next upstream only after an attempt fails or times out (up to 400 ms), and opens a breaker only after ≥ 10 samples with > 50% errors. With one dead upstream that makes the first ~10 queries, and every exploration or half-open probe, wait a full timeout. That breaks T1.5's chaos criterion (p99 within 1.5× of healthy).
Decision
- Hedging: if an attempt hasn't answered within its upstream's hedge delay (≈ 3 × EWMA + 10 ms, bounded to [20 ms, attempt timeout]; 100 ms before there is data), the next member starts in parallel. The first good answer wins.
- Must-hedge cases: exploration picks (
fastestε), half-open probes, members whose last attempt failed, and last-resort unhealthy members all start immediately alongside the next member, so they never add client latency. - Losers finish in the background (detached, not aborted), so their real outcome (often a timeout) reaches the health tracker.
- Breaker: 3 consecutive failures also open it (in addition to the window rule).
Consequences
Measured in-process (debug build), one blackholed upstream out of two: 0 failures and p99 1.04–1.15× of baseline for failover, round_robin, fastest, and parallel. The cost is a few extra upstream queries while a member is degraded, and detached attempts holding a socket for up to one attempt timeout.
ADR-014 Access, rate-limit, and special-name defaultsDNS-014, DNS-019, spec/03 §3 step 4, and spec/08 §6 name these behaviors but leave several defaults open.
Context
DNS-014, DNS-019, spec/03 §3 step 4, and spec/08 §6 name these behaviors but leave several defaults open.
Decision
- Access: queries from outside
[access] allowed_networksget REFUSED + EDE 18. The default list is RFC 1918, 100.64/10 (CGNAT, Tailscale), 169.254/16, 127/8, fc00::/7, fe80::/10, and ::1. Pod/service CIDR auto-detection waits for the Helm work (T4.1). Allowing0.0.0.0/0triggers a startup warning. - Rate limit: token bucket of 1000 queries per 60 s per client (Pi-hole parity), with REFUSED + EDE 18 by default (or drop). IPv4 clients are counted per /32, IPv6 per /64 (a device's rotating privacy addresses share a bucket). Loopback is exempt.
- Special names:
localhost/*.localhost→ 127.0.0.1 / ::1, authoritative (RFC 6761 §6.3).*.invalid→ NXDOMAIN (RFC 6761 §6.4).use-application-dns.net→ NXDOMAIN (Firefox DoH canary).- All CHAOS-class queries → REFUSED.
- Reverse lookups for private ranges → NXDOMAIN unless a local record or an explicit
[[route]]covers them (RFC 6303; Pi-holebogus-priv). .localis not intercepted, because many AD domains use it; mDNS names never reach unicast DNS anyway.
Consequences
Safe by default on a home network. Users exposing TelltaleDNS beyond RFC 1918 must widen allowed_networks deliberately. Each special-name rule can be turned off under [special].
Requirements DNS-014 DNS-019
ADR-015 Image build, allocator tuning, and port 53 for non-root host networkingOPS-001 and spec/08 §2 require a static musl image that runs as 65532.
Context
OPS-001 and spec/08 §2 require a static musl image that runs as 65532. T0.4 turned up three things the spec leaves open:
- Compiling arm64/armv7 under QEMU with fat LTO takes hours on CI runners.
- musl's allocator roughly halved cache-hit throughput in the bench harness (≈100–125k vs ≈190–210k qps for glibc). With mimalloc, transparent huge pages (THP;
madvisemode on WSL and many distros) raised idle RSS from ≈6 MiB to ≈22 MiB, over the 20 MiB gate. - Docker doesn't give added capabilities to a non-root user (no ambient capabilities). The
spec/08§3 Pi compose example (network_mode: host+user: 65532+cap_add: [NET_BIND_SERVICE]) therefore getsEACCESbinding :53. With bridge networking, Docker setsnet.ipv4.ip_unprivileged_port_start=0inside the container, so :53 works there with every capability dropped. Kubernetes pods get the same with containerd ≥ 2.0, or with the safe sysctlnet.ipv4.ip_unprivileged_port_startin the pod securityContext.
Decision
- The build stage runs on
$BUILDPLATFORMand cross-compiles withcargo zigbuild(pinned zig 0.15.2 and cargo-zigbuild 0.23.4, checksums verified). Only the smoke test runs under QEMU. mimallocis the global allocator (as02§3 already says), built withno_thp.- The image keeps
USER 65532:65532, with no file capabilities: file caps break exec when the capability is dropped and are ignored underno_new_privs(allowPrivilegeEscalation: false). For host networking on a Pi, the docs recommend the host sysctlnet.ipv4.ip_unprivileged_port_start=53(one line in/etc/sysctl.d/), which keeps the container non-root. The fallback isuser: "0:0"withcap_drop: [ALL]+cap_add: [NET_BIND_SERVICE]. The Helm chart (T4.1) sets the pod sysctl.
Consequences
CI image builds take minutes, not hours. musl matches glibc throughput, and idle RSS stays ≈6 MiB. The spec/08 §3 compose example must change before the compose bundle ships (T4.3); the owner chooses between the sysctl and the root-with-one-capability fallback as the documented default.
Requirements OPS-001
ADR-016 List fetcher behavior and configurationFLT-004 and spec/05 §3.4 step 1 define the fetcher's duties (conditional GETs, size caps, retries, stored sources) but not its configuration, scheduling, name resolution, or edge cases.
Context
FLT-004 and spec/05 §3.4 step 1 define the fetcher's duties (conditional GETs, size caps, retries, stored sources) but not its configuration, scheduling, name resolution, or edge cases. T2.1 needed answers.
Decision
- Config:
[[list]]entries with a stablename([a-z0-9_-]{1,64}, used in file names, metrics, and the API) and exactly one source:url(https; http allowed with a warning),path, or inlinerules. Pluskind(block/allow),match(subtree/exact, FLT-002),enabled, and per-listrefresh_secs/max_bytes.[filter]holds the defaults: refresh 24 h (minimum 15 min), concurrency 4, 120 s per attempt, 3 retries, 64 MiB. At most 1024 lists (the05§3.1 bitset width). - Location: the fetcher lives in
telltale-filter::fetchand runs only onall/controllernodes, starting after the DNS listeners are bound. HTTP/1.1 over rustls with the built-in Mozilla roots, one connection per download,Accept-Encoding: identityso the size cap is exact. - Retries: network errors, timeouts, 5xx, 408, and 429 are retried with 2 s × 4ⁿ backoff (±25% jitter, capped at 60 s); other 4xx, oversize bodies, and bad redirects are not. Between refreshes, a failing list is retried after 5 min, doubling to 1 h (never later than its normal refresh).
- Sanity: an empty body or an HTML page (
<!doctype html/<htmlat the start) counts as a failure, so captive portals and error pages served with 200 never replace a good list. Identical content (same BLAKE3) is "unchanged" even without validators. - Storage:
lists/<name>.src.zst(zstd level 3) plus<name>.meta.json, each written to a temp file and renamed into place. Inline rules are stored too, so the compiler and explain treat every source the same. Lists removed from the config are deleted; disabled lists keep their files. - Name resolution: the system resolvers minus our own listeners (as UPS-009), falling back to the OS resolver, which may be TelltaleDNS itself. A download is an HTTP client, not a forwarder, so this can't loop.
- Change signal: a
watchcounter bumps only when stored content changes or a list is removed. The compiler (T2.3) subscribes to it.
Consequences
One small request per list per day once lists are cached. A broken upstream list or a captive portal never empties a blocklist. Gzip transfer and per-list custom headers/auth are left for later (P1) if users need them.
Requirements FLT-002 FLT-004 UPS-009
ADR-017 List parsing rules and synthetic golden fixturesFLT-001 and spec/05 §2 list the syntaxes, but not how to tell them apart line by line or what to do with near-misses.
Context
FLT-001 and spec/05 §2 list the syntaxes, but not how to tell them apart line by line or what to do with near-misses. spec/09 §1 asks for golden tests on vendored copies of StevenBlack, HaGeZi Pro, OISD small, an AdGuard DNS filter, and Pi-hole regex samples. Several of those are GPL-licensed data (HaGeZi, AdGuard), which shouldn't be committed into an Apache-2.0/MIT repository.
Decision
- Per-line detection, in order: blank/comment → cosmetic/HTML (
##,#@#,#?#,#$#,#%#,$$, unsupported) → hosts (first token is an IP, zone IDs allowed) → AdBlock (@@,|,||,/regex/, orname^[$mods]) → Pi-hole regex (any regex metacharacter,;querytype=,;invert) →*.name/.name→ plain name. Inline# commentsare stripped outside AdBlock lines. - Semantics for open cases:
name^without||=||name^(subtree). Leading.name= subdomains only, like*.name(the narrower reading of AdBlock's suffix match). In an allow-kind list, every non-exception rule allows. Explicit AdBlock anchors (||,|) beat the list'smatchmode. - Unsupported, not invalid (counted, skipped): cosmetic rules, URL paths and
/regex/containing/, wildcards inside names, IP rules (||192.0.2.1^, which belong to the response IP filter, FLT-015), unknown modifiers, and Pi-hole;reply=. A rule with any unsupported modifier is skipped entirely, never applied without it. - Invalid: malformed names (empty or long labels, characters outside
[a-z0-9_-]after IDNA), unknown query types, empty$client, negated$denyallow,$denyallowon allow rules, regexes thatregex-syntaxrejects (including backreferences and lookaround), and regexes over 1024 characters. Hyphens at label edges are accepted (DNS allows them). - Fixtures: the committed golden fixtures are synthetic files that reproduce each format's real shape (headers, boilerplate, modifiers, malformed lines). The real lists are exercised by an ignored, network-dependent test (
tests/lists_online.rs) that requires < 0.1% invalid lines; it currently reports 0 invalid lines on StevenBlack, HaGeZi Pro (adblock, wildcard, domains), OISD (small, big) and the AdGuard DNS filter.
Consequences
Real lists parse without spurious errors, and browser-only rules never widen DNS blocking. The golden tests stay license-clean. Real-list drift is caught by the online test rather than by snapshot diffs.
Requirements FLT-001 FLT-015
ADR-018 Filter snapshot layout and compile schedulingspec/05 §3 describes FSTs with list bitsets, and spec/02 §5 names per-group blobs (filter-<group>.fst, regex-<set>.dfa, rules.idx).
Context
spec/05 §3 describes FSTs with list bitsets, and spec/02 §5 names per-group blobs (filter-<group>.fst, regex-<set>.dfa, rules.idx). T2.3 needed a concrete on-disk layout, a way to handle *.name scope and the four precedence tiers, a rule for when to compile, and an answer for mmap, which needs unsafe that only telltale-net may contain.
Decision
- Layout (
snapshots/<version>/):subtree-<n>.fst,exact-<n>.fst,subdomains-<n>.fst(reversed-label keys → list-set index), with one shard per compile thread recorded asfst_shardsin the manifest. A key's shard is FNV-1a of its first two reversed labels (com.example.), so every suffix of a query name with two or more labels is in one shard, and a lookup reads at most two shards per scope;listsets.bin, interned entries of four list bitsets each (important-allow, important-block, allow, block), so one lookup yields every tier's lists and a group check is a bitset AND (§3.1);modrules.fst+modrules.json(§3.3);regex.json(patterns + metadata);manifest.jsonwith list IDs, list options, source hashes, and the BLAKE3 + size of every blob. Group selection happens at query time through the bitsets, so there are no per-group blobs: one snapshot serves every group. - Regex: patterns are stored, not serialized DFAs, and compile-checked at compile time with the same engine settings the matcher uses (case-insensitive, linear time). Resolvers build the regex set when loading; whether that fits the 500 ms cold-start budget gets measured in T2.4/T5.3.
- Loading:
Snapshot::openreads blobs into memory and verifies each against the manifest. An mmap loader (a small safe wrapper intelltale-net) can replace it in T2.4 if RSS with large lists needs it. - When to compile: at startup, when stored list content changes, and on reload when the enabled lists or their
kind/matchchange, debounced by 2 s. It's skipped when the newest manifest already matches the inputs, and when no list has been downloaded yet. A failed compile keeps the previous snapshot. The 3 newest snapshots are kept. - CPU and memory: compile threads run at nice 10 (set through
telltale_net::lower_thread_priority), on a dedicated thread rather than tokio's blocking pool. With more than one thread, large lists are cut into line-aligned chunks for parallel parsing, and each worker builds one FST shard.[filter] compile_threadsdefaults to0= half the cores, between 1 and 4 (2 on a Pi 4). This changesspec/05§3.4's "default 1 on Pi", because one Pi core can't meet the00§5 gate.compile_memory(128 MiB) bounds the sort before spilling.
Consequences
One snapshot format for every group and node, and only content-changed blobs ship in cluster sync. Measured on the owner's Raspberry Pi 4 (4 GB, Cortex-A72 1.5 GHz; production Technitium running alongside, compile at nice 10), names from HaGeZi TIF: 2M names in 11.6 s on 1 thread, 6.4 s on 2 (gate ≤ 8 s met), and 4.7 s on 3, at 8.2–9.1 B/name; 1.5M names in 8.6 / 6.2 / 3.7 s. Peak RSS while compiling was 136–204 MiB. Sharding costs about 0.5 B/name per extra thread (still ≤ 10 B/name at 4 threads), and one thread writes one FST per scope, so it loses nothing. Whether compiling on 2 threads at nice 10 disturbs query latency on a Pi is T2.7's acceptance test.
ADR-019 Deterministic device anomaly detectionThe owner wants TelltaleDNS to notice when a device behaves differently: abnormal traffic, many queries to one domain, or IoT/appliance phone-homes, in a safe and deterministic way.
Context
The owner wants TelltaleDNS to notice when a device behaves differently: abnormal traffic, many queries to one domain, or IoT/appliance phone-homes, in a safe and deterministic way. OBS-009 already lists per-client rate anomalies, first-seen domains, NXDOMAIN storms, DGA scoring, and beaconing (P2), but has no per-domain volume, no drift baseline, and no rules on determinism, explainability, or acting on findings.
Decision
Add OBS-013 (P1) and 06 §7.1. Detection uses fixed-math streaming statistics (EWMA, MAD, Space-Saving top-K, cuckoo filters, inter-arrival histograms), timed by event timestamps so replays are reproducible. Every finding carries its evidence. A device must pass a learning period before it can alert. State is bounded and lives in the aggregator, off the query path. Findings only inform: alerts, UI, and MCP. Any response, such as quarantining a device, is a separate explicit, audited action. Beaconing moves from P2 to P1, because periodic phone-home is the motivating IoT case. Scheduled as T3.11, after the aggregator (T3.1).
Alternatives considered
ML models (isolation forests, autoencoders) can catch subtler patterns but aren't deterministic across versions, are hard to explain, and need training data a home network doesn't have. Rejected for v1; the evidence-carrying finding format leaves room for an optional, clearly labeled model-based detector later. Automatic blocking on anomalies was rejected: false positives would break devices silently, against the "DNS never surprises you" principle.
Consequences
Owners get actionable, explainable alerts ("plug contacted 37 new domains; usual 0–1") with no black box, at bounded cost. Detection quality depends on baselines, so the first week after install (or after a device appears) is quiet by design.
Requirements OBS-009 OBS-013
ADR-020 Query-time hash index over the snapshot FSTsT2.4 requires a filter lookup p99 ≤ 1 µs (no regex) on x86.
Context
T2.4 requires a filter lookup p99 ≤ 1 µs (no regex) on x86. Walking the snapshot FSTs byte by byte measured p50 0.65 µs and p99 1.65–1.8 µs on a native x86 machine (Ryzen 5 5500U) with 2.6M names. The walk is a chain of dependent compressed-node decodes, about 30 ns per byte, so tuning can't fix it. ADR-003 picked FSTs for size and O(|qname|) lookups; it didn't fix the query-time structure.
Decision
Keep FSTs as the snapshot and sync format (compact, content-addressed, ~9 B/name). Each resolver builds an in-memory index from them on load: one table per scope, in 64-byte buckets of 8 entries, each entry 48-bit fingerprint | 16-bit list-set ID, with a seeded xxh3 hash (random seed per load) and a 75% load factor. A lookup is one probe per (scope, reversed suffix), almost always one cache line each, and the probes are independent memory reads. A false match needs a fingerprint collision inside one bucket: ~6e-14 per probe, about once per few thousand years of a busy resolver, and the seed changes on every load. Snapshots with more than 65,536 distinct list combinations use the FST walk. A new snapshot is served with the walk the moment it's loaded, and the indexed matcher is swapped in when ready (built at nice 10), so the index never delays a cold start or a list update.
Consequences
Measured with 2.6M real names and no regex: p50 0.32 µs and p99 0.93–0.94 µs on the Ryzen (0.61–0.64 µs on the i5 laptop), versus 1.65 µs for the walk. The cost is ~10 B/name of RAM on top of the FSTs (26.9 MiB for 2.6M names), built in ~0.9 s. At the 00 §5 RSS gate (1M names) that's ~10 MiB, within budget. Zero allocations per lookup in both modes (tested). The regex path adds ~0.2 µs with AdGuard's 29 regexes.
Builds on ADR-003
ADR-021 Groups, clients, and identificationFLT-005/006 and spec/03 §3 step 2 define groups and the identification chain, but not the configuration, the trust model for client-supplied identity, or how group lists meet the compiled snapshot.
Context
FLT-005/006 and spec/03 §3 step 2 define groups and the identification chain, but not the configuration, the trust model for client-supplied identity, or how group lists meet the compiled snapshot.
Decision
- Config:
[[group]] { name, lists?, priority }, where omittedlistsmeans every enabled list. Adefaultgroup always exists; declaring one restricts what unknown devices get.[[client]] { name, match = [IP | CIDR | MAC | "id:<client-id>"], groups = ["default"] }, validated for unknown groups and lists and for a key that identifies two clients.[clients] { neighbor_table = true, neighbor_refresh_secs = 60, trust_edns_mac_from = [] }. - Chain: client ID → EDNS MAC (only from
trust_edns_mac_from: the option is client-supplied, so trusting it by default would let any device claim another's identity) → MAC from the kernel neighbor table (rtnetlink dump, IPv4 + IPv6, intelltale-net, refreshed only while some client is matched by MAC) → exact IP (IPv4-mapped IPv6 canonicalized) → most specific CIDR →default. Lookups are hash maps and a short sorted prefix list; a prefix trie can replace the list if CIDR counts grow. - Groups: a device in several groups gets the union of their lists; other settings come from the highest-priority group. Per-client list masks are precomputed whenever the snapshot or the client config changes. The filter state records the client table it was built for, so a query racing a reload never pairs one table's identity with another's masks.
- Routing: client group names feed
[[route]] match_group. Resolution now looks up the upstream group by the cache view chosen at selection time, rather than re-selecting without groups. - Deferred: hostname-based matching (DHCP leases / rDNS, FLT-006), client IDs until the DoH/DoT listeners (T4.5), and per-group block mode, pause, and schedules (T2.6, FLT-009/010).
Consequences
Pi-hole-style per-device filtering, with MAC identification that survives DHCP and IPv6 privacy addresses on host-networked installs. Identification costs a few hash lookups per query and allocates nothing.
Requirements FLT-005 FLT-006 FLT-009
ADR-022 Block answers, CNAME inspection, and pause semanticsFLT-007/008/009 list the block modes, CNAME deep inspection, and pause, but not the default mode, the TTL, how CNAME inspection meets the policy-neutral cache (spec/03 §4), or what pausing means for a device in several groups.
Context
FLT-007/008/009 list the block modes, CNAME deep inspection, and pause, but not the default mode, the TTL, how CNAME inspection meets the policy-neutral cache (spec/03 §4), or what pausing means for a device in several groups.
Decision
- Block answers per group (
block_mode,block_ips,block_ttl,ede,ede_text), taken from the client's highest-priority group. Defaultnull_ip(A → 0.0.0.0, AAAA → ::, other types → NODATA), matching Pi-hole and Technitium (the owner's current setup); TTL 60 s; EDE 15 with the list name. NXDOMAIN and NODATA answers carry an SOA with the block TTL; block answers are authoritative except REFUSED. This changes T2.4's interim NXDOMAIN default. - CNAME inspection runs wherever an answer leaves the server: cache hits, fresh upstream answers, and stale answers. The cache stays policy-neutral and the check is per client. It walks the answer section's CNAME records without allocating; the first blocked target replaces the whole answer with the client's block answer, with EDE text
CNAME target blocked by list …. - Pause: global and per group, until a deadline, keyed by group name so it survives reloads. A client is paused when its highest-priority group is (consistent with block settings coming from that group). The fast path costs one atomic load when nothing is paused. The control surface is the API (T3.4).
Consequences
Block behavior matches the owner's current resolvers by default and is tunable per group. CNAME cloaking (trackers behind first-party CNAMEs) is caught, for cached answers too, at the cost of a records walk on answers that have CNAMEs.
Requirements FLT-007
ADR-023 Background work under load: priorities, thread counts, and deferred freesT2.7 requires a recompile during the realistic-home run to raise p99 by at most 10%, with zero errors.
Context
T2.7 requires a recompile during the realistic-home run to raise p99 by at most 10%, with zero errors. ADR-018 ran compiles at nice 10 on half the cores (1–4) and left this measurement to T2.7. On the homelab (Ryzen 5 5500U, 6 cores / 12 threads, 2.7M names, 20k qps, bench.py swap), the first measurement showed large p99 spikes, and a long run crashed the server.
Decision
- Priority: background threads (compile, index build, snapshot load, retire) use
SCHED_IDLEon Linux instead of nice 10, throughtelltale_net::background_thread(). Nice 10 still received a share of CPU while workers were runnable. - Thread count:
[filter] compile_threads = 0(auto) now means half the cores (1–4) only while nothing is filtering yet. Once a snapshot has been published, a recompile (list refresh, reload) uses one thread. An explicit number applies to every compile. Even at idle priority, parallel compile threads compete with query workers for SMT siblings, memory bandwidth, and cache. On the homelab, 4 threads compiled in 2.1 s and raised p99 by 143%. One thread took 5.9 s and raised it by 7.5%, and the confirmation run with defaults measured −17.7% (within run-to-run noise). A refresh's result isn't waited for, so a slower compile costs nothing visible. ADR-018's first-compile timing, and with it T2.3's Pi gate, is unchanged. - Deferred frees: swapped-out filter and routing state is dropped on a short-lived background thread after 2 s, so freeing tens of MB never lands on a worker mid-query.
- No blocking work on runtime workers: hashing, checking, and compressing a downloaded list runs on
spawn_blocking. A reload with unchanged upstream config reuses the running router (health state, pooled connections, bootstrap cache). SIGUSR1refreshes every list now (FLT-004), and the bench uses it to trigger a list-driven recompile.
Found while measuring
prefetch (DNS-008) called tokio::spawn from the cache-hit path on UDP worker threads, which have no runtime context. The resulting panic killed the server once a hot entry reached the prefetch threshold. Prefetch now spawns on a runtime handle captured when the pipeline is built (regression test dns_008_prefetch_from_a_non_runtime_thread).
Consequences
A list refresh on a Pi 4 takes about 11.6 s for 2M names (1 thread) instead of 6.4 s, while the first compile keeps 2 threads. Users who prefer faster refreshes over latency can set compile_threads. The p99 measurement is noisy on a shared host: quiet-window p99 alone varied from 65 to 847 µs between rounds, so bench.py swap gates on the median over rounds with alternating order.
Requirements DNS-008 FLT-004
Builds on ADR-018
Referenced by ADR-025
ADR-024 Explain: rule identity, line lookup, and surfacesFLT-013 says every decision records "list ID + rule ID" and that the API can explain "why was X blocked for client Y".
Context
FLT-013 says every decision records "list ID + rule ID" and that the API can explain "why was X blocked for client Y". spec/05 §5 lists what explain returns: the client resolution, groups, schedule state, every matching rule across tiers with list name, line number and text, the winner, and the would-be upstream group. The snapshot (ADR-018) stores plain domain rules only as names in FSTs, with no line numbers. The API comes in M3 (T3.4).
Decision
- Rule identity. A plain domain rule is identified by (list, listed name, scope, tier); modifier and regex rules by their index in the snapshot, which carries their line. That's what
Attributionrecords on the query path (T2.4), and what the query log will store (T3.1). Line numbers aren't added to the snapshot: about 3–4 more bytes per name (+40%) for something only explain needs. - Line lookup on demand. Explain re-parses the stored source of each list that matched (only those lists) and reports every line that produces the matched rule, so a name listed twice shows both lines. If the stored source's hash differs from the snapshot's (downloaded again since compiling), the lines refer to the newer copy and a note says so. A missing source gives the rule without lines, plus a note.
- Every match, not just the winner.
Matcher::matchesreports matches from every list, each marked whether the client's groups use it, in the exact precedence orderdecideuses. The first enabled match is, by test,decide's answer for every mask. The query path'sdecideis the same code made generic over a result sink; an A/B ofmatcher_benchshowed no change (p50 445 → 453 ns, p99 1085 → 1005 ns, medians of 4 alternating rounds). - Whole-pipeline answer. Explain walks the pipeline's own stages on a real parsed query: allowed networks, ANY, special names, local records, identification, filter, pause, routing including RFC 6303 private PTRs. Its outcome is tested to agree with what the pipeline answers. Rate limiting depends on the moment, not the name, so it's left out. Schedules (FLT-010) are P1 and are added when they exist.
- Surfaces.
Pipeline::explainreturns a serializableExplanation, the response body forGET /api/v1/explain(T3.4) and theexplain_decisionMCP tool. Until then,telltale explainruns it offline from the config and data directory (newest snapshot, stored sources, kernel neighbor table), as text or--json.
Consequences
No snapshot growth and no query-path cost. Explaining a name that matches big lists costs a parse of those lists. Measured on the WSL laptop with 2.7M names: 0.15 s for a name in two lists totalling 6.5 MB, and 0.02 s for an unmatched name (peak RSS 54 MiB). A Pi 4 is several times slower. That's fine for an interactive "Why?" but not for bulk use. If the UI needs bulk explain (for example, annotating every row in a log view), the query log's stored attribution covers it without line text.
Requirements FLT-010 FLT-013
Builds on ADR-018
ADR-025 Compile memory peaks vs small memory limits00 §1 targets a 128 MiB Kubernetes pod limit and small Pis (Pi Zero 2 W: 512 MiB).
Context
00 §1 targets a 128 MiB Kubernetes pod limit and small Pis (Pi Zero 2 W: 512 MiB). Steady-state RSS is well within budget: 25–35 MiB with the 09 §2 bench lists (430k names) and about 70 MiB with 2.7M names, roughly 20 B per name plus a ~17 MiB base. But compiling a snapshot is transient and much larger: peak RSS 106–109 MiB with the bench lists and 297 MiB with 2.7M names (homelab and WSL measurements, 2026-10-03). [filter] compile_memory (128 MiB) bounds only the external sort; parsing, FST building, index building, and the old and new matchers coexisting (ADR-020, ADR-023) add to it. In a pod with a 128 MiB limit, a large list set gets the process OOM-killed mid-compile, taking DNS down with it, which violates "DNS never depends on anything else".
Decision (proposed, for the owner):
- Derive the compile budget from the memory limit. When
compile_memoryis unset, read the cgroup v2memory.max(or v1memory.limit_in_bytes) and set the sort budget to about 25% of it, minus current RSS, with a floor of 16 MiB. Smaller budgets spill more and run slower, but stay inside the limit. - Refuse rather than die. Before compiling, estimate the peak from the number of input names (measured: about 115 B per name at 2.7M names and about 250 B at 430k, where the fixed base dominates). If it exceeds the remaining headroom under the limit, keep the current snapshot, log an error, and expose
telltale_filter_compile_skipped{reason="memory"}, instead of risking an OOM kill. - In a cluster, resolvers don't compile. CLU-00x already ships snapshots from the controller; resolver pods only load them (steady state). So the 128 MiB target applies to resolver pods, and the controller (or a standalone node) needs the compile headroom. This goes into the Helm chart defaults (T4.4) and
docs/sizing guidance.
Alternatives considered
Lowering the default compile_memory alone doesn't bound the other phases. mmap-loading snapshots (ADR-018) cuts steady RSS, not the compile peak. Compiling in a child process would isolate an OOM from DNS, but needs a second process in a FROM scratch image and complicates the single-binary model; keep it in reserve if (1)–(2) aren't enough.
Consequences
Until this is implemented (proposed for M4, with the Helm chart), standalone deployments need a memory limit of at least ~3× the steady-state RSS for their list set, or 256 MiB for list sets over ~1M names. docs/ will say so with the measured numbers.
Builds on ADR-018 ADR-020 ADR-023
Referenced by ADR-115
ADR-026 Event capture: byte rings, sizes, and precisionspec/06 §1–2 describes a fixed-size, ~128-byte QueryEvent with a 64-byte inline qname, per-worker rings of 65,536 slots (~8 MiB each), and HDR histograms with 3 significant digits.
Context
spec/06 §1–2 describes a fixed-size, ~128-byte QueryEvent with a 64-byte inline qname, per-worker rings of 65,536 slots (~8 MiB each), and HDR histograms with 3 significant digits. T3.1's AC is "drop counter 0 at 100k qps sustained on x86 with default ring sizes". The footprint priority and the M2 RSS budget (64 MiB with telemetry on) push back on several of those sizes.
Decision
- Variable-length records in a byte ring (
rtrb::RingBuffer<u8>, one per producing thread): a 61-byte header plus the full wire-format qname, lowercased, so ~85 bytes per typical query. A 64-byte inline name would truncate exactly the long names that DGA and anomaly detection (OBS-009/013) care about. A record is written withpush_entire_slice: all of it or nothing, wait-free. A full ring drops the event and counts it per ring (telltale_telemetry_dropped_total{ring}); counters and the Prometheus histograms never drop. - Ring size:
[telemetry] ring_slotsstays the knob, in events of ~128 bytes. The default drops from 65,536 to 4,096 (512 KiB per producing thread). With one aggregator pass every 25 ms, a fully loaded worker (~42k events/s, ~3.5 MB/s) fills it in ~170 ms, about 7× the drain period. With UDP workers plus Tokio workers each owning a ring, the spec default would have cost 8 MiB × threads (over 100 MiB on a 12-thread host). - Rings per producing thread, not per listener worker: deferred answers complete on Tokio threads, so each thread that emits gets its ring on first use (one allocation and one short lock per thread lifetime), then never allocates (tested).
- HDR precision: 2 significant digits (1%) instead of 3. Each 1 µs–60 s histogram with
u32counts is ~10 KiB instead of ~70 KiB. With per-client (256 + other), per-qtype, per-path, per-stage, and per-upstream histograms for the current and previous hour, that's at most ~7 MiB instead of ~45 MiB. Histograms allocate on first use. - Deferred to T3.2: qname/client interning (only the segment store needs it; top-K holds names directly), and SQLite persistence of rollups (
06§3). Until then, the minute and second windows (48 h and 15 min) and the hourly top-K and histograms (current and previous hour) are in memory only. Persistence lands with the store, its retention, and its write budget. - Upstream attribution:
QueryEvent.upstreamstays 0 for now. Singleflight shares one answer between coalesced clients, so per-upstream analytics come fromUpstreamEvents (one per exchange, prefetches included), keyed by the upstream's stable config-order ID.
Measured (homelab, Ryzen 5 5500U, 4 UDP workers, dnsperf on the same host, 2026-10-03):
- AC met: 99,991 qps of
cache-hotsustained for 60 s: 5,999,502 queries, 5,999,502 events, 0 dropped, 0% loss, RSS 41 MiB. - Hot-path cost, A/B against the previous commit, 10 rounds in alternating order: p50 at 50% load 34 → 34 µs; p99 at 50% load 751 → 751 µs (medians); saturated
cache-hotqps 169k → 163k (median, −3.7%, with overlapping run ranges of 149–183k vs 148–183k). An earlier 6-round A/B gave −2.4%. So the event path probably costs 2–4% of saturated cache-hit throughput, within this host's run-to-run spread, and nothing measurable at realistic load. - Locally, 4 producers at 25k events/s each with the default ring and drain period: 0 drops (optimized build).
Consequences
Full names in every event. About 2–8 MiB of rings depending on thread count, and bounded aggregate memory (windows ~1 MiB, top-K ~1 MiB, histograms ≤ ~7 MiB). If a host's aggregator can't keep up (a slow Pi under a flood), events drop and are counted, and DNS is unaffected.
Amended 2026-10-08 (review 04-01, 04-07; amendment Proposed): the epoch (Hub::ts_us = epoch + monotonic elapsed) follows steps of the system clock: the aggregator thread re-anchors it when the wall clock and the epoch-derived time differ by a second or more (one clock read per drain; the query path reads one relaxed atomic). A Pi without an RTC is stepped by NTP after TelltaleDNS starts, which left every later event, query-log row, and rollup minute on the old offset until a restart. Steps are exported (telltale_clock_steps_total, telltale_clock_last_step_seconds). QueryEvent.upstream now records the upstream that answered (the owner's choice, review 04-07): the ID travels with the answer through singleflight, so coalesced clients all name the winner, and cached, local, and blocked answers stay 0. Per-upstream analytics still come from UpstreamEvents, which count every exchange, prefetches included.
Requirements OBS-009
Referenced by ADR-027
ADR-027 Query-log segment format and searchspec/06 §4 defines the query log: hourly columnar segments, blocks of up to 8192 rows, per-column encodings with zstd, a block index with a 1 KiB bloom over name and client IDs, a segment dictionary, dictionary-first search, and a small search pool with "1 thread on Pi by default".
Context
spec/06 §4 defines the query log: hourly columnar segments, blocks of up to 8192 rows, per-column encodings with zstd, a block index with a 1 KiB bloom over name and client IDs, a segment dictionary, dictionary-first search, and a small search pool with "1 thread on Pi by default". T3.2's AC: a search over a 50M-row synthetic dataset in ≤ 2 s on a Pi 4, with the format fuzzed. Measuring against that AC changed several details.
Decision
- Files:
qlog/YYYY/MM/DD/HH-<node>-<part>.seg. A new part starts after a restart, after a dropped block (a later block must never reference dictionary entries only the dropped block introduced), or when the segment dictionary reaches 65,536 names or clients. The cap bounds the writer's memory under floods of unique names. - Self-describing blocks: each block carries the dictionary entries it introduces, so a segment still being written, or cut short by a crash, is readable by walking block headers. The footer (block index, name filter, trailer) is only a faster path, written when a part is finished.
- Encoding: every column is a varint (or byte) stream compressed with zstd level 3; the spec's per-column delta/RLE/frame-of-reference encodings are left to zstd. Measured at 13.9–14.4 B/row (spec: 10–20). Rows are sorted by time within a block.
- Per-section length and checksum table in the block header, so a search reads and verifies only the sections it needs: one positional read for one column, one read for several. Checksums are xxh3-64, which only detects corruption (nothing here is content-addressed). BLAKE3 over every section was a measurable share of a 30-day search on a Pi.
- Blooms keyed by content hashes, not dictionary IDs, so blocks are ruled out before any dictionary is read. The spec's 1 KiB block bloom stays for clients and unfinished segments, but it can't rule out names: blocks hold thousands of distinct names, about 60% false positives. So each finished segment also gets a name filter in its footer (~10 bits and 7 probes per name, ~0.8% false positives). An exact-name search over 30 days skips about 99% of segments after one small read.
- Dictionary handling: dictionaries are decompressed in bulk into one buffer and indexed in place (no allocation per name), read only when a name or client predicate needs them. Output rows resolve their few names sparsely, from just the dictionary blocks holding those IDs. Predicates run column at a time into a selection vector; the time column is skipped when a block lies inside the range.
- Search threads: up to 4 (default
min(cores, 4)), each at the lowest CPU priority via a thread-start hook (SCHED_IDLEin the server and CLI), so a search only uses CPU that DNS doesn't. This changes06§4's "1 thread on Pi by default": on one Pi core, substring and latency searches over 50M rows take ~5 s. A persistent pool claims segments newest first; results merge in order; the pool stops as soon as the page is full. - Write path: the builder runs on the aggregator thread (interning, privacy, rows; no I/O). A writer thread encodes, compresses, appends, writes footers, and runs retention every 10 minutes, fed by a bounded queue of 8 blocks. A full queue drops the block and counts it (
telltale_qlog_rows_dropped_total). DNS never waits on the disk. - Privacy levels apply at write time: 1 stores names as a one-label hash, so identical names still group together; 2 also zeroes clients; 3 writes nothing. Per-group levels (
06§4) wait for group-level config. - Not yet:
nodeis 0 until cluster membership (T5.x); rows from several nodes in one hour aren't merged by time. SQLite rollup persistence (ADR-026) moves to T3.3 with the Prometheus/rollup work, which uses it, sorusqlitearrives with its first consumer.
Measured
- Synthetic 50M rows over 720 hours: 40 Zipf clients, 20k Zipf domains plus 15% one-off tracking names (pessimistic: real homes have far fewer), 15.1M dictionary entries, 686 MiB.
- Pi 4 (Technitium in production on the same Pi, search at nice 10, 4 threads, page cache warm), second round:
| Query | Time |
|---|---|
| exact rare name | 0.21 s |
| rare substring | 1.78 s |
| client + blocked, 1000 rows | 0.43 s |
| type filter with no matches (full column scan) | 0.75 s |
| latency ≥ 1 s (1000 rows across 590 segments) | 1.92 s |
| newest rows, last 2 hours | 0.03 s |
AC met with 4 idle-priority threads, with thin margins on the two heaviest queries. On 1 thread the same two take ~5 s.
- x86 (laptop, 4 threads): every query ≤ 0.9 s.
- Fuzzing:
qlog_segment(raw, checksum-repaired, and structure-aware modes), ~670k runs across three sessions, clean.
Consequences
Interactive search stays within 2 s on a Pi for a month of a busy home's queries, without stealing CPU from DNS. Heavier work (a multi-month substring scan, or a much higher share of unique names) degrades linearly. If that matters, the next step is a cross-segment name index (one dictionary per day or week), not more threads.
Builds on ADR-026
Referenced by ADR-031
ADR-028 API listener, crate boundary, and the read-only first slicespec/07 defines the API conventions and resource map but no listener: 06 §5 says only that /metrics is "also on the API port".
Context
spec/07 defines the API conventions and resource map but no listener: 06 §5 says only that /metrics is "also on the API port". T3.4 (API-001, API-002) is the skeleton, and the owner asked for a UI next for testing, which needs an API. API-003 (auth) is T3.5.
Decision
- Listener: a new
[api]section (enabled = true,listen = "0.0.0.0:8053") on its own TCP listener, also serving/metricsand/livez,/healthz,/readyz(06§5). Port 8053 avoids 53/80/443, Pi-hole's 80, and Technitium's 5380/53443 (the owner runs Technitium on the same Pi). The metrics listener (9153) stays for scrapers. - Crate boundary:
telltale-apiowns routes, request/response types, problem+json, time parsing, and the OpenAPI document. It reaches the server only through aBackendtrait implemented by the binary (api_backend.rs) over the state that already exists for/metrics, the pipeline, the aggregates, the query log, and list status. So the API never depends on pipeline internals, and the trait is the seam a cluster proxy (federated scope) plugs into later. - OpenAPI:
utoipa6 generates OpenAPI 3.1 from the handlers. Doc comments become summaries and descriptions, and a test fails if any operation lacks either (AGT-001).docs/api/openapi.jsonis committed, and a test fails when it drifts (07§1); regenerate withUPDATE_OPENAPI=1. - Conventions: camelCase JSON with units in field names;
{"items": [...]}for collections; cursor pagination on the query log (other collections are small and unpaged for now);from/toas RFC 3339 or relative offsets; errors as RFC 9457 problem+json with a stablecodeenum and ahint;scopeaccepted on analytics calls, whereclusterandnode:localmean this node and anything else is a 400unsupported_scopeuntil clustering. - Read-only until auth: T3.4 ships GET endpoints only (system info, stats summary/timeseries/top/latency, query log, explain, lists, groups, clients, upstreams). Mutations, with dry-run and idempotency keys (AGT-002/003), come with T3.5 so nothing can be changed without authentication. Until then the listener answers only
[access] allowed_networks, like/metrics. - Roadmap order: at the owner's request (2026-10-04: "we will want a UI to help with testing"), T3.5 and T3.9 come next. T3.3 (Prometheus set, Grafana, SQLite rollups) moves after T3.9; none of the UI depends on it, since the dashboard reads the in-memory windows.
Consequences
The UI and agents have a documented, typed surface now. Every later endpoint follows the same pattern (types and doc comments in telltale-api, data from Backend).
Requirements AGT-001 AGT-002 API-001 API-002 API-003
ADR-029 Local sign-in detailsspec/08 §6 and API-003 define local users (Argon2id), session cookies with CSRF, opt-in HTTP Basic, scoped API tokens, TOTP, lockout, RBAC, and a first-run setup token.
Context
spec/08 §6 and API-003 define local users (Argon2id), session cookies with CSRF, opt-in HTTP Basic, scoped API tokens, TOTP, lockout, RBAC, and a first-run setup token. Several details are left open.
Decision
- Storage: users, recovery codes, sessions, and tokens in SQLite
<data_dir>/state.db(rusqlite, bundled). Session IDs, token secrets, and recovery codes are stored only as BLAKE3 hashes (they are 256-bit random, so a slow hash adds nothing); passwords as Argon2id PHC strings with the08§6 parameters. Ifstate.dbcan't be opened, the API stays off and DNS keeps answering (rule 5). - Cookie
Secureflag: set only when the request arrived over HTTPS (X-Forwarded-Proto: https), because the API has no TLS listener yet and browsers dropSecurecookies over plain HTTP, which would make sign-in impossible on a LAN.HttpOnly; SameSite=Strictalways. Revisit when the API gets TLS (08§6 asks forSecureunconditionally). - CSRF: a per-session random token returned at sign-in and by
GET /auth/status, required asX-CSRF-Tokenon every non-GET request authenticated by the cookie. Tokens and Basic don't need it (they are not sent automatically by browsers). - Tokens:
tt_<16-char id>_<43-char secret>. Scope caps the owner's role (read→ viewer,write→ operator,admin→ admin). The optional group scope from08§6 is deferred to thefamilyrole (P1). - Basic: per-user opt-in; refused over plain HTTP unless
[auth] allow_insecure_basic; verified results cached 60 s keyed by a salted BLAKE3 hash of the credentials, invalidated on any change to the user. Users with TOTP can't use Basic (it would bypass the second factor). - Password policy: 10–256 characters, no composition rules (NIST SP 800-63B).
- Lockout: per username and per client address, 5 free failures, then 30 s × 2^n up to 15 minutes; in memory (a restart clears it). Unknown usernames take the same time as wrong passwords (a dummy Argon2 verify).
- Sessions: 7 days absolute, 1 day idle (configurable in
[auth]);last_seenis written at most once a minute; a password change ends the user's other sessions; disabling or deleting a user ends all of theirs; hourly purge. - TOTP: RFC 6238, SHA-1, 6 digits, 30 s, ±1 step; each step is accepted once (replay protection); ten recovery codes, single-use.
[auth] totp_required_rolesrefuses password-only sign-in for those roles until TOTP is enrolled (403). - First admin: the setup token is kept in
<data_dir>/setup-token(mode 0600) and logged, so it survives restarts andtelltale auth setup-tokencan print it; it is deleted once an admin exists. Deployments can instead setTELLTALE_BOOTSTRAP_ADMIN_USERwith..._PASSWORDor..._PASSWORD_HASH(Argon2id PHC only;telltale auth hash-passwordmakes one), applied only while no user exists. - What is open:
/api/v1/auth/{status,setup,login},/api/v1/openapi.json, and the probes. Everything else, including/metricson the API port, needs a sign-in. The dedicated metrics listener (:9153) stays unauthenticated and limited toallowed_networks, so existing scrapers keep working. The API listener also stays limited toallowed_networks(defense in depth). - AGT-002/003: the auth endpoints don't take dry-run or idempotency keys: sign-in, sign-out, and token creation aren't meaningful to preview, and replaying a token creation must not mint a second secret silently. Configuration mutations will take both when they land.
Consequences
The API is safe to expose on the LAN, and the UI (T3.9) can build sign-in and first-run screens on /auth/status. TLS on the API listener and OIDC (API-004) are later tasks; until TLS, HTTP Basic needs a TLS-terminating proxy.
Amended 2026-10-08 (review 06-01, 06-08; amendment Proposed): at most four password checks (Argon2id, 19 MiB each) run at once; beyond that sign-in and uncached HTTP Basic answer 503 with Retry-After: 1, not counted as a failure, so a burst of sign-ins can't run the node out of memory (02 §8). HTTP Basic failures now count against the address as well as the username, and a success clears both, as the sign-in form does.
Requirements AGT-002 API-003 API-004
ADR-030 Web UI MVP: serving, security, and scopeT3.9 (API-005) asks for a UI MVP: login, dashboard, query log + explain, groups, lists, upstreams, local DNS, and settings, ≤ 400 KiB gzipped, with a Playwright suite.
Context
T3.9 (API-005) asks for a UI MVP: login, dashboard, query log + explain, groups, lists, upstreams, local DNS, and settings, ≤ 400 KiB gzipped, with a Playwright suite. ADR-009 fixes the stack (Svelte + uPlot, embedded). The API has no configuration mutations yet, no live tail (T3.7), and no OIDC (T3.6).
Decision
- Serving:
telltale-apiembedsui/distwithrust-embed(allow_missing, so Rust builds and tests work without Node; such a binary serves a one-paragraph page saying the UI isn't built). The API router's fallback serves the UI for everything outside/api/. Static files need no sign-in (they hold no data); every data call does. - Routing: hash routes (
#/queries?client=...), so the server only serves/,/assets/*, and/favicon.svg, and filters are bookmarkable without server-side SPA fallbacks. - Security: CSP
default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none', plusX-Frame-Options: DENY,nosniff, andReferrer-Policy: no-referrer(08§6). The UI never uses inline scripts,styleattributes, or{@html}; dynamic styles go through Svelte'sstyle:directive (CSSOM, allowed by CSP). The Playwright suite fails on any console error, so CSP violations fail CI. HSTS waits for TLS on the API listener. - Size and caching:
npm run buildwrites a.gzbeside each text asset (served onAccept-Encoding: gzip, so no compression crate on the server) and fails over 400 KiB gzipped. Hashedassets/*areimmutable;index.htmlisno-cache. The MVP is ~60 KiB gzipped. - Types:
src/lib/api-types.tsis generated fromdocs/api/openapi.json(openapi-typescript) and committed; CI fails if it is stale, so the UI breaks at type-check time, not at runtime, when the API changes. - Build: Node is build-time only: a
node:24stage on the build platform buildsdist/once for all image architectures. CI'suijob runs svelte-check, the budget, and Playwright (headless Chromium) against a real binary with an inline blocklist and a local record (no internet needed). - MVP scope versus
07§3: shipped: first-run setup and sign-in (with TOTP and recovery codes), dashboard (KPIs, stacked queries by status, upstream exchanges, where time goes, top domains/blocked/clients, upstream share and health; every chart has a table view), query log (filter chips, URL filters, cursor paging, per-row timing bar, "Why?" drawer), explain page, clients (seen and configured), groups, lists, upstreams (breaker, latency), settings (password, TOTP, tokens, users, system), light/dark themes, 360 px layout. Deferred: OIDC sign-in (T3.6); editing groups, lists, upstreams, and local records, and the allow/block quick actions (need the configuration API with AGT-002/003); live tail via SSE (T3.7; the log polls with "Auto-refresh" meanwhile); a virtualized table (100-row pages for now); the schedules editor, presets gallery, client merge and profile pages, analytics, cluster page and scope selector (M5), and the audit log (T3.8). The TOTP enrollment shows anotpauth://link and the key rather than a QR code, to avoid a QR dependency. The latency tile shows the upstream p90 because the API exposes p50/p90/p99, not p95.
Consequences
The resolver ships its own UI in one binary with no runtime dependencies. Each new API feature can add its page incrementally; the suite and the type drift check keep the UI and API in step.
Requirements AGT-002 API-005
Builds on ADR-009
ADR-031 Prometheus metric set and SQLite rollupsT3.3 completes the 06 §5 metric set (OBS-005, OBS-011), ships a Grafana dashboard, and persists rollups (06 §3, moved here by ADR-027).
Context
T3.3 completes the 06 §5 metric set (OBS-005, OBS-011), ships a Grafana dashboard, and persists rollups (06 §3, moved here by ADR-027). Rule 4 forbids new hot-path work without a benchmark.
Decision
- Where series come from: the per-thread counters (T1.9) stay as they are. Series that need per-event detail (
telltale_stage_duration_seconds,telltale_upstream_duration_seconds{upstream,protocol},telltale_blocked_total{group,list}, opt-intelltale_client_queries_total{client}) are built by the aggregator thread from query/upstream events into cumulative classic histograms (the same 50 µs–5 s buckets) and counters. Nothing new runs on the query path; the cost is that these series miss events dropped by a full ring, whichtelltale_telemetry_dropped_totalalready reports. The only hot-path change is one increment fortelltale_cache_prefetch_total, under a lock already held. bench-smoke after the change: 191,533 qps cache-hot, 0% loss. - Bounded cardinality: blocks are keyed by (group, list) with at most 4,096 pairs; per-client series are off by default and capped at
per_client_cap(default changed 256 → 100 to match06§5), the rest summed asclient="other"; upstream series follow the configured upstreams. - Label deviations from
06§5: nonodelabel on every series: Prometheus already addsinstance, andnodeis ontelltale_build_infofor joins; cluster-wide views will add it when nodes federate (M5).qtypestays in its own family (telltale_queries_by_qtype_total) instead of a third label ontelltale_queries_total, keeping that family at 22 series instead of 286.telltale_upstream_requests_totalgainsoutcome(success,failure) from the router's health counters (every attempt, including health probes) and replacestelltale_upstream_failures_total.telltale_ratelimited_totalmirrorsqueries_total{status="rate_limited"}. The DNSSEC, cluster, and qlog-write-time series wait for their features. - Grafana:
deploy/grafana/telltale-dashboard.jsonis generated bydeploy/grafana/build.py(CI checks it is current) and uses only exported metrics, with datasource/job/instance variables. - Rollups:
<data_dir>/rollups.db(separate fromstate.dbso analytics writes never contend with sign-in):rollup_minute(7 days),rollup_hour(400 days),rollup_day(forever),hour_top(top 100 per kind per hour),hour_latency(percentile summaries per hour; HDR histograms are not stored, since percentiles of merged hours aren't needed yet and summaries are tiny). Counts are a versioned little-endian blob with column counts in the header, so later builds read old rows. A background task flushes completed minutes once a minute, re-writing the last 3 minutes for late events but never minutes from before the process started (so a restart can't overwrite saved data with a partial view); hour and day rows are recomputed from the rows below on every write (idempotent). Retention runs hourly. All SQLite work is on a blocking thread; open or write failures are logged and the API falls back to memory (rule 5). - API:
stepgainshourandday(rollups);step=minutemerges stored minutes with memory (memory wins);stats/summarysums hours for ranges beyond 48 hours. The dashboard UI adds 7- and 30-day ranges.
Consequences
Charts and summaries reach back 400 days and survive restarts with ~1 KiB per stored minute at most. Querying a past hour's top-K through the API is not exposed yet (stored for the analytics pages).
Amended 2026-10-08 (review 04-04, 04-11; amendment Proposed): the hourly top lists in rollups.db follow the query log's privacy level: names hashed exactly as the query log stores them at level 1 and above, no list of clients at level 2 and above (they were kept in plain text for 400 days at every level). A restart in the minute after an hour closes no longer loses that hour's lists: once the startup replay is over, the writer stores them if nothing is stored for the hour.
Requirements OBS-005 OBS-011
Builds on ADR-027
ADR-032 Live tail over Server-Sent EventsOBS-008 / 06 §6: GET /api/v1/queries/stream (SSE) "or /ws", server-side filters (client, group, status set, qname glob, upstream, min latency, node), and a per-subscriber rate cap (default 500 events/s) with inline drop counts.
Context
OBS-008 / 06 §6: GET /api/v1/queries/stream (SSE) "or /ws", server-side filters (client, group, status set, qname glob, upstream, min latency, node), and a per-subscriber rate cap (default 500 events/s) with inline drop counts. Rule 4 forbids hot-path cost; rule 5 says telemetry can't affect DNS.
Decision
- SSE only, no WebSocket: one-way delivery is all a tail needs; SSE works through proxies, reconnects by itself in browsers (
EventSource), and authenticates with the session cookie (GET, so no CSRF header). A WebSocket can be added later if a use appears. - Source: the aggregator thread's sink (beside the query-log writer) copies query events into a
tokio::sync::broadcastchannel (4,096 deep) only while a subscriber exists; otherwise the cost is one atomic load per event. The query path is unchanged. The query-log privacy level applies (1: names hashed, 2: clients hidden too, 3: no tail). - Per subscriber: a task filters raw events (no allocation: names are compared via a reused buffer), applies a token bucket (
rate, default 500/s, 1–2000), formats matches as the sameQueryRowasGET /queries, and every second reports skipped matches asevent: dropped{dropped, reason: "rate"|"lag"}(lag: it fell behind the broadcast channel or its own 256-item queue). Filters:name+match(substring, exact, suffix, glob; regex is rejected to keep per-event cost bounded),client,group(primary group),status,qtype,upstream,minLatencyMs.nodewaits for clustering. At most 16 concurrent streams per node (503 beyond). A comment every 15 s keeps idle connections open. - Measured (bench-smoke on the dev box, two subscribers attached for the whole run: one unfiltered at the 500/s cap, one with a name filter matching nothing; two rounds in opposite order): cache-hot 196k and 169k qps with subscribers vs 143k and 166k without, 0% loss in all runs; p99 at 50% load 3.4/3.5 ms vs 2.6/4.0 ms (within this machine's noise); p50 at 50% load 655/367 µs vs 167/215 µs, a small but consistent median cost under heavy load from formatting on the aggregator and subscriber tasks sharing CPU with the load generator. Acceptable for an operator watching a live view; zero when nobody watches.
- UI: the query log's "Auto-refresh" polling becomes Live (EventSource with the page's filters; rows flushed every 250 ms; newest 500 kept; skipped count shown). Rcode and time filters don't apply to a live view and the page says so.
Consequences
"Watch it happen" works in the UI, from curl -N, and for agents. Subscriber tasks share the async runtime with upstream I/O, which is why the cap, the subscriber limit, and allocation-free filtering matter; if a home ever needs more, move subscribers to a dedicated thread.
Requirements OBS-008
ADR-033 Audit log: what, where, and how it is chainedAPI-006 asks for an audit log of every config change (who, when, diff), replicated cluster-wide; 08 §6 makes it append-only and hash-chained; AGT-005 asks for agent attribution and a reason.
Context
API-006 asks for an audit log of every config change (who, when, diff), replicated cluster-wide; 08 §6 makes it append-only and hash-chained; AGT-005 asks for agent attribution and a reason. Today the only mutations are sign-in administration and config reloads (config editing through the API comes later).
Decision
- Storage: an
audittable instate.db(migration 2):seq,ts,actor,actor_kind(session,token,basic,system),remote,action,target,detail(JSON),reason,prev_hash,hash.hash = BLAKE3("telltale-audit-v1" ‖ prev_hash ‖ seq ‖ ts ‖ each field with a presence byte and length), genesisprev_hash= 32 zero bytes. SQLite triggers abort UPDATE and DELETE. Verification recomputes every hash and link and also checks thatseqhas no gaps; truncation at the end is caught by comparing the head hash with a copy kept elsewhere (documented). - What is recorded now:
user.create|update|delete,user.password,user.totp.enable|disable,token.create|revoke,auth.login(successful sign-ins),auth.lockout(the first lock of a username or address, not every failed attempt, so a password-guessing run can't flood the log), first-admin creation by setup token or bootstrap environment, andconfig.reload(the dotted paths of changed settings, never values: configs may hold secrets). Updates record{field: {from, to}}; passwords and secrets appear only as"changed". - Attribution: sessions and Basic record the username; API tokens record
token:<name> (owner: <user>)(AGT-005); an optionalX-Telltale-Reasonheader (trimmed to 500 characters) is stored as the reason (required for agents once plan/apply exists). - Failure handling: the change happens first and the entry is appended after; if the append fails, the error is logged rather than undoing the change (the audit store is the same SQLite file, so this is rare). Moving to "write the entry in the same transaction" comes with the config-edit API, where it matters most.
- Access: admins only (
GET /api/v1/auditwith cursor paging and action-prefix/actor filters;GET /api/v1/audit/verify), the UI's Settings → Audit log, andtelltale audit list|verifyoffline. No retention: entries are small and the log is append-only by design. - Deferred: cluster replication (M5, with the config change log), MCP client name/version in entries (with the MCP server), signed head checkpoints.
Consequences
Sign-in administration and reloads are accountable now, and every future mutation endpoint gets auditing by calling Auth::record with a diff.
Requirements AGT-005 API-006
ADR-034 OIDC sign-in: library, flow, and account rulesAPI-004 / 08 §6: Authorization Code + PKCE, discovery, multiple providers, claim → role mapping, JIT provisioning, optional verified email, back-channel and RP-initiated logout, ID-token validation with JWKS rotation, local sessions after sign-in, a canonical public_url, and disable_local_login with a break-glass admin from allowed_admin_networks.
Context
API-004 / 08 §6: Authorization Code + PKCE, discovery, multiple providers, claim → role mapping, JIT provisioning, optional verified email, back-channel and RP-initiated logout, ID-token validation with JWKS rotation, local sessions after sign-in, a canonical public_url, and disable_local_login with a break-glass admin from allowed_admin_networks. AC: integration tests against Keycloak and Authentik.
Decision
- Library:
openidconnect4 (the02§7 crate) without its bundled HTTP client. The binary supplies requests through the list fetcher's existing hyper + rustls client (system resolver, Mozilla roots, no redirects followed, 1 MiB, 15 s), so no second HTTP/TLS stack. Cost: ~0.6 MB gzip on the binary (RSA/EC/Ed25519 verification crates).cargo denyflags RUSTSEC-2023-0071 (rsa, Marvin timing attack): it concerns RSA private-key operations; TelltaleDNS only verifies ID-token signatures with providers' public keys and holds no RSA private key, so the advisory is ignored indeny.tomlwith that justification, to be revisited when a fixedrsaships. - Flow:
GET /api/v1/auth/oidc/{id}/start(302 to the provider with PKCE S256, state, nonce) andGET .../callback. Flow state lives in memory for 10 minutes (max 1,000), keyed bystateand bound to the starting browser by an HttpOnlySameSite=Laxcookie (Strict cookies aren't sent on the provider's redirect back), so a callback can't be replayed or completed in another browser.returnTomust be a UI hash route (no open redirect). Failures redirect to/#/?loginError=.... - Validation:
openidconnectchecks signature (provider JWKS), issuer, audience, expiry, and nonce; allowed algorithms are the asymmetric ones (RS/PS 256–512, ES256/384, EdDSA), never HMAC. Discovery (with keys) is cached for an hour and refreshed once when a signature doesn't verify (key rotation). Custom claims (groups, nestedrealm_access.roles) are read from the verified token's payload; claim paths are dotted. - Accounts: users are keyed by (provider,
sub) (state.dbmigration 3) and created on first sign-in with an unusable password; the username comes frompreferred_username(orusername_claim, thenemail, thensub). A provider account is never attached to an existing local account by name (that would let a provider-side rename take over an account); a clash yieldsname@provider. The role is the highest any group maps to (default_roleotherwise; none: refused), re-evaluated at every sign-in and audited when it changes. TOTP is the provider's job for these accounts. Disabled accounts stay disabled. - Sign-out: RP-initiated with
client_idandpost_logout_redirect_uri = <public_url>/(from discovery'send_session_endpoint). Noid_token_hint, to avoid storing ID tokens; Keycloak therefore asks for confirmation, and Authentik's default invalidation flow shows its own page. Back-channel logout is deferred (needs provider session IDs and a token endpoint for logout tokens; sessions are short and local). - Break-glass:
disable_local_loginrefuses password sign-in and HTTP Basic except for admins fromallowed_admin_networks(default: private networks and loopback);/auth/statustells the UI whether to show the password form for this client. - Tests: an in-process fake provider (discovery, JWKS, token endpoint, RS256 tokens) covers the flow in unit tests;
ui/tests/oidc/compose.ymlruns Keycloak 26.4 and Authentik 2025.10.1 with fixtures, and theoidcCI job runs the Playwright spec against both (admin, viewer, refused user, sign-out). - Proxies:
[api] trusted_proxies(default none): for requests from those addresses, the client is the rightmostX-Forwarded-Forentry that isn't a trusted proxy (entries further left are client-controlled). Without it, everything behind an ingress shares the ingress address: one client's failed sign-ins would lock out everyone, and audit entries and break-glass checks would see the proxy.X-Forwarded-Proto: httpskeeps setting the cookie'sSecureflag (ADR-029). - Resilience: a provider that can't be set up (missing secret file) is logged and skipped; password sign-in and the API keep working.
- Deferred: back-channel logout, multiple redirect URIs per node (cluster nodes share
public_urlfor now), OAuth for agents (AGT, P1).
Consequences
Homes and labs with an identity provider sign in with it; local passwords become optional. Each provider must be registered with the exact redirect URI.
Requirements API-004
Builds on ADR-029
Referenced by ADR-071
ADR-035 Helm chart: allInOne first, client-IP proof in CIT4.1 / OPS-002/003 asks for allInOne, scaled, and daemonSet shapes, Services with externalTrafficPolicy: Local, PDB, HPA, probes, NetworkPolicy, ServiceMonitor, cert-manager, existingSecret everywhere; AC: ct install on kind and k3d arm64, DNS via the LB, and client IPs preserved in the query log in an e2e test.
Context
T4.1 / OPS-002/003 asks for allInOne, scaled, and daemonSet shapes, Services with externalTrafficPolicy: Local, PDB, HPA, probes, NetworkPolicy, ServiceMonitor, cert-manager, existingSecret everywhere; AC: ct install on kind and k3d arm64, DNS via the LB, and client IPs preserved in the query log in an e2e test. Clustering (M5) doesn't exist yet.
Decision
- Shapes: only
allInOne(one StatefulSet with a volume) until M5.scaledanddaemonSetneed a controller to distribute filter snapshots and share users/sessions; without it every replica would compile its own lists and keep its own sign-in state, so sign-in would break across replicas.values.schema.jsonrefuses the other modes with that reason. HPA and PDB belong toscaledand come with it (a PDB on one replica only blocks node drains). - Ports: in the pod's own network the container listens on 5353 and the Service maps 53 → 5353 (no capability needed).
hostNetwork: truebinds 53 on the node, which needs root: the container then runs as UID 0 with every capability dropped exceptNET_BIND_SERVICE, read-only root, no privilege escalation (Kubernetes doesn't grant ambient capabilities to non-root users). - Services: one DNS Service with UDP and TCP 53 (mixed protocols, k8s ≥ 1.26),
externalTrafficPolicy: Localby default, LB annotations passed through;NOTES.txtwarns onCluster. A separate API Service (ClusterIP by default; LoadBalancer or Ingress optional) and a ClusterIP metrics Service for the ServiceMonitor. - Probes: startup and readiness on
/readyz(listeners bound), liveness on/livez. NopreStopsleep: the image has no shell, and the binary already drains on SIGTERM (readiness off first, OPS-007). - Secrets: bootstrap admin from an existing Secret (env vars), any Secret mounted read-only under
/etc/telltale-secrets/<name>(optional mounts let the pod start without them). - Monitoring: ServiceMonitor, a PrometheusRule with five default alerts, and the Grafana dashboard ConfigMap (the same JSON as
deploy/grafana/, kept identical bybuild.py --check). - cert-manager: only through the Ingress (annotations); a DoT/DoH
Certificatecomes with those listeners (T4.5). - Proof:
deploy/helm/e2e.shcreates a kind or k3d cluster with MetalLB, installs the chart, queries through the LoadBalancer from the host (UDP and TCP), and asserts the query log recorded the host's own address on the cluster network, not a node or pod address. CI runs it on kind (amd64) and k3d (arm64), thenct installwithci/*-values.yaml. The script uses a private kubeconfig (it never changes the caller's current context) and gives k3d its own pod/service ranges so it also runs on a host that is itself a k3s node. Also verified on the owner's homelab (k3s + Cilium LB IPAM): real LAN clients appear with their own addresses.
Consequences
One-replica deployments are production-ready on any LoadBalancer implementation; HA within Kubernetes waits for clustering.
Requirements OPS-002 OPS-007
ADR-036 Guided UI: task-first names, help panels with diagrams, Simple/AdvancedThe owner (2026-10-04): Pi-hole is easy for non-experts; Technitium is powerful but assumes DNS expertise (zones, SOA, zone types).
Context
The owner (2026-10-04): Pi-hole is easy for non-experts; Technitium is powerful but assumes DNS expertise (zones, SOA, zone types). TelltaleDNS should offer the depth without demanding the expertise, and visual cues should help novices see what an option does. Recorded as API-011.
Decision
- Names by outcome, jargon as subtitle. UI features are named for what they achieve; the DNS term appears underneath and in help: "Names on my network" (local zone / records), "Send a domain to another server" (conditional forwarding), "Block / allow lists" (filter lists), "Where answers come from" (upstreams). No separate "zones" concept in the UI: local names are grouped by domain, and SOA/NS are generated. Real zone features (import, secondary/transfer, serving a domain to other servers) live under Advanced on the same page.
- One glossary, many readers.
docs/help/topics.jsonholds each topic:id,title(outcome),term(technical name),summary(one sentence),when(when you'd want it),example(home-network example),caution(what goes wrong if misused),diagram(kind, optional),docs(anchor). The UI imports it, the site renders it as a glossary page, and CI checks every help id used in the UI exists (and every topic is used or marked general). API descriptions stay in code (AGT-001) but use the same terms. - Help panels. A "?" beside any setting opens a right-side panel with the topic in that fixed order (summary, diagram, when, example, caution, term, docs link). Keyboard and screen-reader accessible; no modal blocking the form.
- Diagrams. A
FlowDiagramSvelte component draws inline SVG (no library, CSP-safe, a few KiB): a device, TelltaleDNS, and the possible destinations of a query (cache, local name, blocked, another server, upstream), with the path for this option highlighted and the user's own values filled in (*.sororlab.dev → 192.168.5.122). The same component illustrates help topics, previews changes before they're saved, and shows the Explain result ("this query went here, because of this rule"). Status colors and labels match the rest of the UI; every diagram has a text equivalent. - Simple / Advanced. Each editing page has a Simple view (common fields, safe defaults) and an Advanced view (everything); the choice is remembered per user. Advanced never hides information that changes behavior: if an advanced field differs from its default, Simple shows a "customized in Advanced" note.
- Previews and tests. Every change shows a plain-language sentence plus diagram before saving (built on the API's dry-run, AGT-002), and each rule offers "Test a name" (explain).
- Experts keep everything. Config files, API, and CLI expose the full model; the UI never becomes the only way to do something.
Consequences
More UI content to write and keep current (one glossary file, checked in CI). The editing pages (T3.12 and later) are designed around this from the start; the read-only pages get help panels and diagrams in T3.11.
Requirements AGT-001 AGT-002 API-011
ADR-037 Masked-client-IP detector: what counts as infrastructureOPS-003 / spec/08 §3.2: "the resolver detects when > 90% of queries come from ≤ 3 IPs inside the pod/node CIDRs and raises a UI banner".
Context
OPS-003 / spec/08 §3.2: "the resolver detects when > 90% of queries come from ≤ 3 IPs inside the pod/node CIDRs and raises a UI banner". A pod doesn't know the pod or node CIDRs, and on a homelab the node network is the LAN, so "inside the node CIDR" would also match real devices.
Decision
- Window: the aggregator keeps per-client counts for fixed 10-minute windows (≤ 256 clients each, off the query path). The detector reads the last complete window (or the current one before the first completes). At least 100 queries are needed to judge.
- Infrastructure = loopback; this host's default gateways from
/proc/net/routeand/proc/net/ipv6_route(inside a container that's the Docker bridge or the pod's gateway, where SNAT'd traffic appears; on a host network it's the LAN router, which matters when the router forwards everyone's DNS); node addresses inTELLTALE_NODE_IPS(the chart sets it fromstatus.hostIP); and[clients] infrastructurenetworks for anything else (pod networks, other nodes). - Rule: masked when the ≤ 3 heaviest clients that are infrastructure sent > 90% of the window's queries. A household whose traffic is dominated by a few real devices is never flagged, because they aren't infrastructure (conservative: a missed warning costs less than a false one).
- Surfaces:
clientIpsMaskedinGET /api/v1/system/info(share, sources, queries, window start), a UI banner linking to the docs (refreshed every minute), the gaugetelltale_client_ips_maskedwith a chart alert after 30 minutes, and one log line per change. Evaluated only on API calls and scrapes.
Consequences
Multi-node clusters with externalTrafficPolicy: Cluster SNAT to other nodes' addresses, which are only recognized when listed in [clients] infrastructure (the chart's install notes already warn on Cluster).
Requirements OPS-003
ADR-038 Native installs: release assets, signing, install.sh, self-update, Compose defaultsOPS-004 / spec/08 §4 asks for a static binary with systemd unit and install script that "downloads + verifies (cosign/minisign)", telltale self-update, and the Compose bundle of §3.4.
Context
OPS-004 / spec/08 §4 asks for a static binary with systemd unit and install script that "downloads + verifies (cosign/minisign)", telltale self-update, and the Compose bundle of §3.4. No release pipeline existed, and the §3.4 Compose example (user: 65532 + cap_add) can't bind port 53: Docker doesn't give a non-root user ambient capabilities.
Decision
- Assets: the image workflow's cached cross-build also exports the static musl binaries (
--target bin) astelltale-{x86_64,aarch64,armv7}-linuxplusSHA256SUMS. Every push to main replaces theedgeprerelease; av*tag creates a release and pushes image tagsX.Y.Z,X.Y,X,latest. - Signing: minisign, not cosign. Ed25519 over
SHA256SUMS, verifiable offline with a 56-character public key (deploy/release/telltale-release.pub, also built into the binary), byinstall.sh(theminisignpackage, installed if missing) and byself-update(theminisign-verifycrate: MIT, no dependencies). Cosign's keyless flow would need Sigstore's trust root and network access in both. The secret key is the repository secretMINISIGN_SECRET_KEY; without it releases aren't published (the job warns) rather than published unsigned. - install.sh: POSIX
sh, root, systemd required. Refuses on any signature or checksum mismatch and test-runs the binary before installing it; creates thetelltalesystem user; never overwrites/etc/telltale/telltale.toml(the starter config isdeploy/compose/telltale.toml, shared with Compose); swaps the binary atomically; asks (or--disable-resolved-stub) before turning off systemd-resolved's stub; waits for/readyz. Stable is the default channel; until a release is tagged it says to use--edge. - Unit: a static
telltaleuser (notDynamicUser, so the CLI can run as the same user against the same files),StateDirectory=telltale,ProtectSystem=strict, onlyCAP_NET_BIND_SERVICE(ambient and bounding),@system-serviceminus@privileged, netlink allowed (neighbor table),MemoryMax=256M. - self-update: verifies the signature, compares this binary's SHA-256 with the release's (so "up to date" needs no version parsing and works for
edge), downloads, checks the hash, runs--version, keepstelltale.old, renames atomically;--restartrestarts the unit. Refused in containers. No key or URL override in production (the tests call the library with a fixture key). - Compose: host network, read-only root,
cap_drop: [ALL],cap_add: [NET_BIND_SERVICE], as root by default with./databind-mounted (Docker creates it root-owned; a root process withoutCAP_DAC_OVERRIDEcan't write the image's 65532-owned directory, which the e2e caught). Running unprivileged is documented: host sysctlnet.ipv4.ip_unprivileged_port_start=53+chown 65532 data. - Health:
telltale health [--url](an HTTP GET to/readyz) is the container healthcheck, since the image has no shell. - Tests: CI job
nativeruns the Compose bundle (host network, healthcheck, hardening) andinstall.shagainst a locally served release signed with a throwaway key: DNS on 53 over UDP and TCP, runs astelltale, upgrade keeps the config, a swapped binary and a modifiedSHA256SUMSare both refused.
Consequences
The owner must add MINISIGN_SECRET_KEY once before the first binaries are published. The cluster-version check of self-update (spec §4) waits for clustering (T5.11).
Requirements OPS-004
ADR-039 DoT and DoH listeners, client IDs from certificates, PROXY v2 rulesT4.5 (DNS-002/003) and T4.4 (DNS-020).
Context
T4.5 (DNS-002/003) and T4.4 (DNS-020). The spec names SNI and DoH-path client IDs (<clientid>.dns.example.com) but not how the server knows which part of the name is the ID, how certificates are reloaded, or how PROXY headers are trusted.
Decision
- One connection path. DoT is the TCP listener with a TLS acceptor (ALPN
dot) in front; the TCP code now serves any stream, so pipelining, out-of-order answers, idle and in-flight limits are identical. DoH is hyper (HTTP/2 by ALPNh2, else HTTP/1.1; both already in the tree via axum) over the same TLS setup, calling the sameQueryHandler. No new crates: rustls, tokio-rustls, hyper, hyper-util, rustls-webpki were already dependencies. - Client IDs come from the certificate. If the leaf certificate has a wildcard name
*.Xand the SNI is<label>.X, the label is the client ID. Noserver_namesetting to keep in sync with the certificate. The DoH path/dns-query/<id>wins over SNI. IDs are one DNS label, lowercased, carried inline inRequestMeta(a 64-byteCopyvalue, no allocation). - Certificate reload: each TLS listener polls its two files' (mtime, length) every 10 s and swaps the
CertifiedKeybehind aResolvesServerCert; new handshakes use it, open connections keep theirs; a failed read keeps the old one. Polling (not inotify) works with Kubernetes Secret symlink swaps and needs no new dependency. - DoH responses:
Cache-Control: max-age= min(answer/authority TTL, negative TTL),max-age=0without records. Requests the resolver never sees: wrong path or invalid client ID → 404, wrong media type → 415, other methods → 405 withAllow, bad base64url or shorter than a DNS header → 400, body over 64 KiB → 413. A handlerDrop(e.g. rate limit) → 503. - PROXY protocol v2 only, and mandatory when enabled. A listener with
proxy_protocol = truereads a v2 header before TLS on every connection and closes connections without one (accepting both would let clients pick their address). v1 text headers are refused.LOCALkeeps the socket address. TLVs are skipped. There is no source allowlist: exposure must be limited by the network (documented). - Telemetry:
Protogainsdotanddoh(metrics labels, query log, aggregates). Rollup rows carry a transport-column count (format version 2); version-1 rows still decode. - Helm:
encrypted.dot/encrypted.dohadd ports to the same DNS LoadBalancer (soexternalTrafficPolicy: Localkeeps client IPs), with a certificate from an existing Secret or a cert-managerCertificate; pods listen on 8853/8443 unless on the host network.
Consequences
Clients must trust the certificate (a public CA, or a private one distributed to devices). DoQ and DoH3 remain skipped until their listeners (M7). (Update: both shipped in M7, T7.7 and T7.8.)
Requirements DNS-002 DNS-020
ADR-040 Config made through the API before clustering: managed entries in state.dbAPI-002 (everything configurable through the API) and API-010 (name a device from the UI) need API writes now.
Context
API-002 (everything configurable through the API) and API-010 (name a device from the UI) need API writes now. Today the configuration is files only (on Kubernetes, the chart's values), and spec/12 describes the end state: a change log held by the cluster primary, with GitOps mode refusing writes. Rewriting config files from the server would fight GitOps and lose comments.
Decision
- Managed entries, merged by name. Entries written through the API are stored in
state.db(managed(kind, name, body JSON, updated, updated_by)), starting withclient. At every load (startup,SIGHUP, an API write) the effective config is the files plus the managed entries whose names the files don't use. The files are authoritative: file entries are read-only through the API (409), and the API can't create a name the files use. - Never breaks DNS. If the merged config doesn't validate (a group a managed device names was removed from the file), the files alone are used and the reason is logged until it's fixed (rule 5). API writes are validated against the full resulting config before they're stored (422 with the field).
- Write semantics that map onto the future change log: one
config_versioncounter instate.db, bumped by every write and returned as theETagofGET /clientsand in each response;If-Matchrefuses stale writes (412version_conflict);?dryRun=truevalidates and reports (before/after, warnings, and an impact estimate: recent queries from the matched addresses, from the aggregator's current and previous hour) without storing;Idempotency-Keystores the first successful response for 24 h per user and replays it (a different request with the same key is 409). Writes need operator (or awritetoken) and are audited (client.put,client.delete) with their author. - Applying: the API stores the entry, then asks the main loop to reload (the same path as
SIGHUP, minus its own audit entry), and answers after the swap. - Names are resolved at read time from the address (the query log stores addresses, not names; the old lookup by the client's index at query time mislabelled history after any client list change), so naming relabels history and live views at once.
Consequences
With clustering (M5), the managed table becomes change-log entries on the primary and replicates (CLU-003); GitOps mode will refuse these writes (409 gitops_managed). Other kinds (groups, records, routes; T3.12) reuse the same table and endpoints.
Requirements API-002 API-010 CLU-003
ADR-041 Importing zones: per-name local records, not a whole-zone authorityThe owner is moving the internal sororlab.dev zone from Technitium (split-horizon: the same domain is public in Route 53).
Context
The owner is moving the internal sororlab.dev zone from Technitium (split-horizon: the same domain is public in Route 53). T6.4 (Technitium importer) covers zones as "local names and routes". Technitium is authoritative for the whole zone (unknown names get NXDOMAIN); TelltaleDNS local records are per name (unknown names go upstream).
Decision
telltale import zone FILE converts RFC 1035 zone files (Technitium export, BIND, PowerDNS) into [[record]] TOML for the supported types (A, AAAA, CNAME, PTR, TXT, MX, SRV), skips SOA/NS at the apex, and lists everything else in a header report; the output is validated as config before it's written. Imported names behave as per-name local records, so unimported names under the zone fall through to the upstreams (the owner chose this: public-only hosts keep working from inside). A "this server owns the whole zone" mode (NXDOMAIN for unknown names) belongs to T3.12's zone-lite view.
Consequences
The rest of T6.4 (forwarders and conditional forwarders → upstreams and routes, block/allow lists, and a Technitium API source) remains open. Moving a zone: import, add the records while keeping the route to the old server (local records win over routes, so anything missed still resolves there), compare answers name by name, then remove the route.
ADR-042 Names on my network and forwarded domains through the APIT3.12 (API-011, DNS-010, UPS-007) needs local names and conditional forwarding editable from the UI and the API, following ADR-040's managed entries.
Context
T3.12 (API-011, DNS-010, UPS-007) needs local names and conditional forwarding editable from the UI and the API, following ADR-040's managed entries.
Decision
- Two more managed kinds:
record(an owner name and the set of its records; a write replaces the set) andforward(a domain and its servers). A forward expands at merge time into upstreamsforward:<domain>#n(a bare address meansudp://), afailoverupstream groupforward:<domain>, and a route for the domain with a DNSSEC negative trust anchor (internal domains are usually unsigned). The names are reserved for this purpose. - Files win: a name with any file
[[record]], or a domain any file route matches exactly, is read-only (409). Validation runs the whole merge (files + every API entry + this change) through config validation and the local-record value parser, so a bad value is a 422 with the field, before anything is stored. - Zone-lite, not zones: the UI groups names by their parent domain and never asks for SOA or NS. Per-name answers fall through to upstreams for unknown names (ADR-041); a whole-zone authority mode stays deferred.
- Endpoints:
GET /records,PUT/DELETE /records/{name},GET /forwards,PUT/DELETE /forwards/{domain}, with the device endpoints' dry-run,If-Match, idempotency, operator role, and audit (record.put,forward.delete, ...). The write path is shared code. - Applying reuses the reload path; a forward change rebuilds the upstream router (health state of unchanged upstreams is rebuilt too, as on any upstream change).
Consequences
The UI covers the common cases without the config file. Hosts files stay file-only. The MCP tools for these writes come with T6.6 / M7.
Requirements API-011 DNS-010 UPS-007
ADR-043 Anomaly engine v1: concrete detectors, thresholds, and persistenceT3.13 implements OBS-013 under ADR-019 and spec/06 §7.1, which fix the principles (deterministic, explainable, learn first, bounded, alert-only) but not the statistics, thresholds, or how state survives restarts.
Context
T3.13 implements OBS-013 under ADR-019 and spec/06 §7.1, which fix the principles (deterministic, explainable, learn first, bounded, alert-only) but not the statistics, thresholds, or how state survives restarts.
Decision
- Statistics: exponentially weighted mean and mean absolute deviation (MAD-style) per baseline,
f32, plain IEEE arithmetic only (noexp/ln), FNV-1a hashing, ordered maps, event-time windows (an hour closes when an event from a later hour arrives). Findings are byte-identical across runs and architectures (a golden file checked on x86-64 and arm64 in CI). - Detectors and bars (sensitivity
normal= 6 × spread;low8,high4): rate spike per hour of day (≥ 300/h, ≥ 2× usual, > usual + k·spread; spread ≥ max(10, 25% of usual)); domain volume per tracked (device, eTLD+1) pair (≥ 200/h, ≥ 3× usual, pair tracked ≥ 24 h; spread ≥ max(5, 25%)); drift (≥ 20 new registrable domains a day, > usual + k·spread; day one isn't part of the baseline); beacon (≥ 10 gaps of 30 s–1 h with σ ≤ max(5% of the period, 2 s) for 4 consecutive hours, only when the pair wasn't periodic during learning, which keeps TTL-driven refreshes quiet). A rate spike that domain findings explain (≥ 50% of the excess) is reported only as those. Alerted windows don't update their baseline. - eTLD+1 is approximated without the Public Suffix List: the last two labels, or three under common second-level suffixes (
co.uk,com.au, ...); reverse names are skipped. - Bounds: 16 tracked pairs, 128 learned fingerprints, 24 hourly baselines per device (≤ 4 KiB, tested); 1024 devices by default, least recently seen evicted (counted).
- Persistence: the whole engine as JSON in
<data_dir>/anomaly.json, written hourly and on shutdown (temp file + rename);float_roundtripkeeps reloads bit-exact. Unreadable state just restarts learning. - Surfaces:
GET /api/v1/analytics/anomalies?since=, the Anomalies page,telltale_anomalies_total{kind}with a chart alert (TelltaleDNSDeviceAnomaly, info), and later thefind_anomaliesMCP tool (T6.6). Off at privacy level 3.
Deferred
the TTL-violation flag (events don't carry answer TTLs yet), per-group sensitivity, mute/acknowledge, and the alert-rule engine's own destinations (OBS-010).
Requirements OBS-010 OBS-013
Builds on ADR-019
ADR-044 Cluster channel v1: identity files, reusable join tokens, and peer trustT5.1 implements CLU-001 under spec/12 §3, which fixes the shape (Ed25519 CA, join token with CA fingerprint + URLs + secret, 90-day node certificates, mTLS over HTTP/2 with protobuf, persistent streams, replicas dial out) but not the token format, how peers name each other in TLS, where identity lives, or whether a token is single-use.
Context
T5.1 implements CLU-001 under spec/12 §3, which fixes the shape (Ed25519 CA, join token with CA fingerprint + URLs + secret, 90-day node certificates, mTLS over HTTP/2 with protobuf, persistent streams, replicas dial out) but not the token format, how peers name each other in TLS, where identity lives, or whether a token is single-use.
Decision
- Token:
tt_join_+ base64url(JSON{v, cluster_id, cluster_name, ca_fp, urls, secret, exp}) (the spec'svgl_join_predates the rename). Tokens are reusable until they expire, becausespec/12§3 has every Kubernetes pod join with one long-lived token from a Secret. The primary stores only SHA-256 hashes of token secrets (tokens.json) and compares them in constant time. - Join: the joining node pins the CA by fingerprint against the chain the server sends (CA included), so the secret is never sent to a server outside the cluster. The primary picks the certificate's names itself, ignoring names in the CSR.
- Peer names: every node certificate carries
cluster.telltale.invalidand<node-id>.node.telltale.invalid(plus its advertise hosts). Peers verify against the cluster CA and the shared name, so trust doesn't depend on the address a peer was dialed at (LAN IP, ingress, NAT). The peer's node ID comes from its certificate, and its Hello must match it. Node ID = the first 16 hex digits of SHA-256(public key). - Identity on disk:
<data_dir>/cluster/{cluster.json, ca.crt, node.crt, node.key, ca.key (primary only), tokens.json}, keys mode 0600, atomic writes.telltale cluster init|joinwrite it, and the server reads it at startup (a restart joins the channel). - Wire: 4-byte big-endian length + prost
Frame{Hello|Heartbeat}, 1 MiB frame limit,POST /cluster/v1/streamwith a streaming body in both directions, heartbeats every 5 s, peer down after 15 s, reconnect with full-jitter backoff (1 s doubling to 30 s).POST /cluster/v1/joinis the only route served without a client certificate. - Port:
[cluster] listen, default0.0.0.0:8443perspec/12. That is the same port our docs use for DoH, sotelltale config checkwarns about the clash rather than changing either default. - Dependencies:
prostis new (pure Rust; frames only, no codegen step);rcgenmoves from dev-only to runtime for issuing certificates.
Deferred
certificate renewal at 2/3 of life and CA rotation (with T5.4 promotion, since only the CA holder can issue), eligible-to-eligible streams, and ephemeral Kubernetes members joining from a Secret at startup (T5.10). The cluster health page and per-peer lag (T5.9); heartbeats carry qps, which is still 0.
Requirements CLU-001
Referenced by ADR-057
ADR-045 Sign-in across cluster nodes: replicated identities, per-node sessions and OIDC callbacksspec/12 §6 says users, RBAC, and OIDC config are part of the replicated config, so sign-in works on every node even with the primary down.
Context
spec/12 §6 says users, RBAC, and OIDC config are part of the replicated config, so sign-in works on every node even with the primary down. It doesn't say where sessions live, how one OIDC client serves several nodes reached at different URLs, or what happens to users that already exist on a node that joins (the owner's homelab node has its own users, and the Pi got its own admin before clustering).
Decision
- Replicated from the primary (with T5.2, writes forwarded by T5.7): local users (Argon2id hashes, roles, TOTP secrets), API-token hashes and scopes, OIDC provider settings and group-to-role rules, and the session-revocation list. So a break-glass local admin works on every node, even when the identity provider is down.
- Per node, never replicated (CLU-006 node-local keys):
auth.oidc.public_url, because each node is reached at its own URL, and each one is registered as a redirect URI on the same OIDC client (<public_url>/api/v1/auth/oidc/{id}/callback);- the OIDC client secret file, because secrets stay in each node's own Secret or file and never cross the cluster channel.
- Sessions are per node. Signing in to a second node with SSO is a silent redirect (the provider already has a session). There is no shared session-signing key to protect or rotate. Sign-out and revocation replicate, so revoking a user ends their sessions everywhere within one sync.
- If the owner later puts one hostname in front of several nodes without sticky sessions, cluster-signed session tokens (signed with the cluster key) are the follow-up; that's not needed for the hybrid Pi + k8s setup.
- Join merges identities once: on its first sync, a joining node offers its local users and tokens to the primary.
- A user that doesn't exist on the primary is added.
- A name that exists on both keeps the primary's record, and the joiner's copy is listed under Conflicts. Nothing is silently dropped.
- After that, the primary is the only source.
Consequences
the owner registers one extra redirect URI in Authentik per node (for the Pi, e.g. https://telltale-pi.sororlab.dev, or its LAN address). The Pi's OIDC needs its own copy of the client secret.
Implementation (T9.1, 2026-10-06):
- Users, recovery codes, and tokens travel as one JSON blob (
identities.json) named by the signed manifest. Replicas take it in one transaction, keyed by user name; rows carry anorigin:clusterorlocal. - Changed from the decision above, to keep the change small: replicas refuse identity changes (409, naming the primary's site) instead of forwarding them.
- Changed from the decision above: users only on a replica stay local instead of being offered to the primary once. They keep working on that node; to share one, make it on the primary.
- A name on both nodes takes the primary's record (logged), as decided.
- The OIDC provider settings already replicate with the shared configuration. The session-revocation list isn't needed: deleting or disabling a user on the primary removes or blocks them everywhere on the next sync, and with them their sessions.
Requirements CLU-006
ADR-046 Version management: one build identity, a signed release index, and update status everywhereowner request 2026-10-04: the user should always know which version they run, which versions exist, and whether they're on the latest, the same way on every architecture and install type.
Context
owner request 2026-10-04: the user should always know which version they run, which versions exist, and whether they're on the latest, the same way on every architecture and install type. Today an edge binary reports only telltale 0.1.0 (the Pi shows exactly that), so two different builds look identical, and nothing tells the user an update exists.
Decision
- One build identity, stamped by CI into every artifact from the same commit (the amd64, arm64 and armv7 binaries and the multi-arch image):
- version: the tag's semver for releases;
<next>-edge.<run>for main builds, e.g.0.1.0-edge.47; - commit: short SHA;
- build date, and channel (
stableoredge); - target: e.g.
aarch64-unknown-linux-musl; - install type:
native,containerorhelm, from an environment variable the systemd unit, Dockerfile and chart set.telltale --versionprints all of it on one line. A local dev build saysdevand its commit, so it's never mistaken for a release.
- version: the tag's semver for releases;
- Where it shows:
- the UI footer and an About/Updates panel under Settings;
GET /api/v1/system/info(build {...});- the metric
telltale_build_info{version,commit,channel,target} 1; - each node's Hello on the cluster channel, so the Cluster page lists every node's version and flags mixed versions (CLU-010: N/N-1 only, replicas upgrade first);
- an MCP tool later.
- Which versions exist: every release publishes a signed
releases.jsonnext toSHA256SUMS, signed with the same minisign key. It lists the channel, version, date, commit, notes URL, assets per architecture with hashes, and the oldest version it can upgrade from. Theedgeindex lists the latest main build. Clients trust only a verified index, never the GitHub API's unsigned listing. - Latest or not: a node fetches its channel's index once a day (
[updates] check = trueby default; off for air-gapped or privacy-strict sites; through the normal HTTP client, never blocking DNS) and compares semver within its channel; edge compares by run number.- Status:
up to date,update available <version> (<date>),newer than the index(a dev build), orunknown(check off or failing; the last success time is shown). - The status is in the UI, in system info, and in
telltale_update_available 0|1, with an optional info-level alert.
- Status:
- How to update, per install type: the panel shows the right step. Native:
sudo telltale self-update --restart. Compose:docker compose pull && docker compose up -d. Helm: the chart version andhelm upgrade, or bumping it in GitOps values. A cluster gets the replicas-first order.- The UI never self-updates a node: replacing the binary needs root, and container installs are the orchestrator's job.
- Architecture independence: one index serves all architectures; a node picks its asset by target. Versions and commits are identical across architectures because CI builds them all from one commit in one workflow (already true of
image.yml). The image tag and the binary version always match.
Consequences
small CI changes (stamp the version; generate and sign releases.json), a [updates] config section, and the API/UI/metric fields. telltale self-update switches from reading SHA256SUMS to reading the index (same signature check).
Addendum (2026-10-05, T6.9 implemented):
- Stamping:
crates/telltale/build.rsreadsTELLTALE_BUILD_{VERSION,COMMIT,DATE,CHANNEL}. CI computes them once per run and passes the same--build-args to every buildx call, so all three architectures (and the image) carry one identity, whichimage.ymlchecks in each binary. Edge is<Cargo version>-edge.<run number>, a tag is its semver, and a PR build is-pr.<run>on channeldev. - Install type: the image sets
TELLTALE_INSTALL=container, the charthelm, and the systemd unitnative. Without it, the binary guesses from/.dockerenvor the Kubernetes environment. - Release index:
deploy/release/index.pywritesreleases.json(channel, version, commit, date, notes, and per-target asset names and hashes fromSHA256SUMS). It's signed and verified in the release job next toSHA256SUMS. - Update check: nodes fetch
releases/download/edge/releases.json(edge) orreleases/latest/download/releases.json(stable), plus.minisig, and verify with the built-in key. Edge builds compare by run number, and a release sorts after its edge builds. The first check is 60 s after start, then daily, retrying hourly after a failure. - Not yet:
self-updatestill readsSHA256SUMS(it works and is verified the same way). Moving it onto the index is a follow-up, as is an MCP tool for update status.
Requirements CLU-010
ADR-047 Config replication v1: whole-version manifests, shared vs node-local sectionsT5.2 implements CLU-003 under spec/02 §5 and spec/12 §4.
Context
T5.2 implements CLU-003 under spec/02 §5 and spec/12 §4. Those describe a change log of semantic ops (JSON-Patch) that replicas replay, with a full snapshot as fallback. They don't say which settings are node-local before T5.5, how UI/API-made entries (ADR-040) replicate, or what a replica does with its own lists and config file.
Decision
- Whole versions, not ops (for now). Each change produces a new manifest
(epoch, seq)naming the complete shared configuration as one blob (canonical JSON, a few KB) and the filter snapshot's blobs. A replica converges to the newest manifest from any state, including after a long partition, with no log to replay or compact. BLAKE3 content addressing keeps transfers incremental: a config change ships one small blob, and a list change ships only the FST shards that changed.- Measured: p95 190 ms over a simulated 50 ms RTT (the AC is 5 s). On two local processes, a primary edit was answered by the replica 0.39 s later.
- The per-change log (author, op, timestamp) with orphan detection is still needed for fencing and conflicts (T5.4); it will ride alongside, recording what each version changed.
- Shared vs node-local: node-local =
config_version,node,cluster,listen,api,auth,telemetry,cache(a superset of CLU-006's list: sign-in stays local until ADR-045 replicates identities). Everything else is the primary's effective configuration: files plus UI/API entries (ADR-040), so devices named on the primary reach every node.- A setting the replica doesn't recognize (a newer primary) fails validation, and the replica keeps its last version rather than dropping it silently.
- T5.5 adds the explicit
node.tomlallow-list and the startup error for overriding shared keys.
- Signing: manifests are signed with the cluster CA's Ed25519 key and verified against the CA certificate every node already has.
spec/12mentions a separate signing key rotated with the CA; this reuses the CA key until CA rotation (T5.4) needs the split. - Replica behavior once synced:
- its effective configuration = its own node-local sections + the primary's shared ones;
- its own list fetcher stops, and it installs the primary's snapshot as
snapshots/<version>; - it refuses API config writes with 409
conflictnaming the primary, until T5.7 forwards them; - it records the applied manifest in
cluster/applied.jsonand serves it at cold start without contacting the primary (CLU-004). - Until its first sync, a new replica runs on its own configuration (DNS keeps filtering while it joins).
- Primary: checks twice a second for a changed shared configuration (by hash) or a new snapshot, and republishes with
seq + 1, persisted incluster/published.json. Peers get the current manifest when their stream connects, and every new one at once. Replicas report their appliedseqin a heartbeat sent immediately after applying, so lag is visible without waiting for the 5 s tick.
Consequences
with the Pi as primary, the homelab node's GitOps values for shared sections stop applying once it syncs. The Pi's config file (or UI) becomes the place to change shared settings, until write forwarding (T5.7) and a GitOps primary option are settled with the owner.
Requirements CLU-003 CLU-004 CLU-006
ADR-048 Config authority: the cluster, not whichever node is primary, decides where configuration comes fromowner question 2026-10-04: in a mixed cluster (a Kubernetes node managed by GitOps plus a Pi edited by hand), nothing stops the wrong node from becoming primary, by cluster init on it, a promotion during an outage (T5.4), or a quorum election.
Context
owner question 2026-10-04: in a mixed cluster (a Kubernetes node managed by GitOps plus a Pi edited by hand), nothing stops the wrong node from becoming primary, by cluster init on it, a promotion during an outage (T5.4), or a quorum election. That node would then replicate its own file over the GitOps one: every node follows a config nobody committed, and the next GitOps sync fights it. Recency or availability must not decide what the source of truth is; the operator does, once.
Decision
- The cluster records its config authority in its signed cluster state, set at
cluster init. It is either:gitops, which names the source (e.g.homelab-charts:charts/argocd-apps/values.yaml#telltaledns); orapi: the primary's file plus UI/API edits, as today.
The authority can only be changed by an explicit, audited telltale cluster set-authority run on the current primary, which bumps the epoch.
- Each node declares how its own configuration is managed with
[cluster] config_source = "gitops" | "file"(the Helm chart setsgitops), and reports it in its Hello. The Cluster page shows it next to every node. - Only matching nodes may be primary.
- Under
gitopsauthority, onlygitopsnodes are eligible to publish configuration:cluster initon a non-GitOps node refuses--config-authority gitops, and elections and promotions skip ineligible nodes. - Under
api, any eligible node may be primary.
- Under
- Emergency primaries never change configuration. If every authority-matching node is down, an operator may still promote another node (
telltale cluster promote --emergency). It keeps nodes coordinated (leases, certificates, alerts) but publishes nothing new.- Every node keeps serving the last authoritative version, the API stays read-only, and the UI says so ("GitOps primary unreachable since …").
- When an authority-matching node returns it takes over again automatically; the emergency primary had no writes, so nothing is orphaned.
- Manifests carry their provenance: the authority, the source, and, for GitOps, the revision (commit) the primary loaded. A replica rejects a manifest whose authority differs from the cluster's. The UI shows "configuration from homelab-charts @ <commit>" on every node.
- GitOps write rules (OPS-005) apply cluster-wide: under
gitopsauthority, API config writes on any node return409 gitops_managed. Operational actions (pause blocking, flush cache, list refresh) still work. Node-local settings stay in each node's own file in both modes. - Mixed architectures are supported. Every replicated blob is architecture-independent: the FSTs, list sets written as explicit little-endian, and JSON. The cluster's anomaly golden-file check already runs on amd64 and arm64. A cross-architecture snapshot test (compile on amd64, load and match on arm64 and armv7) joins CI with T5.11.
Consequences
- A misconfigured or recovering node can't silently take over the configuration.
- The price is that with a GitOps authority, configuration is frozen (never lost or wrong) while no GitOps node is up. That is the right trade for a homelab where the k8s node is the one being changed.
- Implemented with T5.4 (elections and promotion) and T5.7 (writes); until then the owner's cluster uses the convention of the homelab node as primary, set up by hand.
Amended 2026-10-08 (review 05-04; amendment Proposed): the takeover promised above now works by hand: promote is allowed over a running primary only when it is the emergency primary of the version this node applied and this node would publish; its newer epoch fences the emergency primary, which had no writes. The Cluster page offers it on a Git-managed replica. The automatic takeover is still open (an owner question).
Requirements OPS-005
ADR-049 Git as the cluster's config source: the primary fetches, the cluster distributesowner idea 2026-10-04, building on ADR-048: let nodes that aren't in Kubernetes (the Pi) also take their configuration from the Git repository, an "external control plane" every node sources from.
Context
owner idea 2026-10-04, building on ADR-048: let nodes that aren't in Kubernetes (the Pi) also take their configuration from the Git repository, an "external control plane" every node sources from. Two shapes were weighed:
- Every node pulls Git. Rejected as the default:
- nodes poll on their own schedules, so they run different commits for a while and "which config is live?" has no single answer;
- every node needs repository credentials, outbound internet, and a Git client;
- a GitHub outage or rate limit hits each node separately;
- the DNS server has to resolve the forge's name to fetch its own config.
- Git is the authority; the cluster distributes. Chosen.
Decision
- A
gitconfig source. On the primary,[cluster.config] source = "git"withrepo(HTTPS),ref(branch or tag),path(a TelltaleDNS TOML file holding the shared sections), an optionalcredentials_file(read-only deploy key or token, never in the repo), andpoll(default 60 s; a push webhook endpoint can trigger a fetch early). - The primary fetches; the cluster distributes.
- The primary fetches the file at the ref's current commit, over HTTPS through the bootstrap resolvers (as list downloads do, UPS-009).
- It validates the file merged with each node's local sections and publishes the result as the usual signed manifest (ADR-047), recording provenance: repo, path, commit SHA, commit author, and commit time.
- Replicas follow over the cluster channel unchanged, so every node converges to the same commit, lag is visible, and nodes keep the last good version when GitHub or the link is down (CLU-004).
- Any Git-capable node can be primary. Under ADR-048, a
gitopsauthority now names the repository rather than a kind of node. Any eligible node that has the source configured and can reach it may publish: the Pi becomes a valid primary for a GitOps cluster, which removes most of ADR-048's frozen-config case. A node configured without the source stays ineligible. - Guardrails:
- Pinned source:
repo,refandpathare pinned in the signed cluster state; changing them is acluster set-authorityoperation. - Optional commit signatures:
require_signed = truewith an allowed-signers file (SSH or GPG keys) accepts only commits signed by those keys. Without it, anyone who can push to the repository controls DNS on every node, which is already true for Argo-managed nodes; this makes it explicit and enforceable. The UI and docs recommend it. - Validate before publish: a commit that fails validation is never published; the cluster stays on the last good commit and raises an alert naming the commit and the error.
- History only moves forward: a force-pushed or rewound ref is refused unless
allow_rewind = true(rollbacks are new commits). - Bounded fetch: size limit, timeout, TLS with system roots, no submodules, no LFS.
- No secrets in the repository: config refers to secret files (as
client_secret_filealready does). - Audit: every published commit is in the audit log with its SHA and author.
- Pinned source:
- Direct-pull fallback (off by default):
[cluster.config] direct_fallback = truelets a replica cut off from every primary for longer thanfallback_after(default 1 h) fetch the same pinned source itself, with the same signature and validation rules. It applies the result locally only, and drops it when the cluster channel returns. - Repository layout: the shared configuration lives in a plain file (e.g.
telltale/shared.toml) instead of TOML embedded in Argo values. The k8s chart reads the same file (an Argo multi-source app, or the chart'sconfigFiles), so one artifact feeds both the primary's Git source and Kubernetes. Each node's own settings stay in its local config or Helm values. - Writes: under a Git source the API is read-only for shared configuration (
409 gitops_managed, OPS-005). The UI offers "propose this change" as a patch or a link to the file on GitHub, never a direct commit (writing to the repository is out of scope for v1).
Consequences
- Config changes for the whole cluster are Git commits, reviewed and versioned like the rest of the homelab.
- The cluster runs one fetcher, and every node shows which commit it serves.
- New: a small Git-over-HTTPS fetch (no Git binary), signature verification (SSH signatures via the existing crypto, GPG optional), and a webhook endpoint.
- Builds on T5.2 (replication) and T5.4 (authority and elections); it is T5.12.
Implementation notes (T5.12, 2026-10-05):
- Configuration: the settings are
[cluster.git](repo,ref,path,credentials_file,poll_secs,require_signed,allowed_signers_file,allow_rewind,max_bytes,webhook_secret_file), node-local, rather than[cluster.config] source = "git". - Authority:
- A Git source makes the published authority
gitops, so the existing409 gitops_managedand promotion rules apply. - A node with
[cluster.git]counts as Git-managed (config_sourcereported asgitops).
- A Git source makes the published authority
- Pinning:
- The manifest carries
source(repo, ref, path, commit, author, time, subject, signer), and replicas store the pin. - A promoted node whose
[cluster.git]differs refuses to fetch and says so; changing the source means changing it on the primary.
- The manifest carries
- Transport:
telltale-gitspeaks protocol v2 over smart HTTP:ls-refs, a commits-only fetch for the history check, a blob-less fetch for the tree, then the one blob. It handles packfiles with deltas, withminiz_oxidethe only new dependency.- Plain
http://is accepted only on loopback, for tests. - Verified against github.com and against real
git upload-packin tests.
- First start: before the first good commit, a Git primary publishes nothing, so a fresh primary never pushes an empty configuration. It serves the last version it applied.
- Alerts:
TelltaleDNSGitCommitRefusedandTelltaleDNSGitSourceFailing. Metrics:telltale_cluster_git_*andtelltale_cluster_config_commit. - Deferred:
- the direct-pull fallback;
- GPG signatures (SSH signatures only);
- the UI's "propose this change";
- the Helm chart reading the same
shared.toml(use an Argo multi-source app meanwhile).
Requirements CLU-004 OPS-005 UPS-009
Referenced by ADR-051
ADR-050 Network groups: groups match subnets, and groups are an analytics dimensionowner request 2026-10-05: categorize devices by VLAN (Management 192.168.1.0/24, IOT .2, AUX .3, LAB .5, SONOS .6, Surveillance .7, Trust .10) and see, per category, what traffic each kind of device makes.
Context
owner request 2026-10-05: categorize devices by VLAN (Management 192.168.1.0/24, IOT .2, AUX .3, LAB .5, SONOS .6, Surveillance .7, Trust .10) and see, per category, what traffic each kind of device makes.
- Today: a subnet can only be matched by a
[[client]]entry (match = ["192.168.2.0/24"]). That entry also names every device in it, so the query log would show "IOT" instead of each device. - Groups: they carry filtering (their lists, block mode), but they're not a first-class dimension in analytics. Per-group data exists internally (time series and blocked counters) without a UI or API around it.
Decision
[[group]] networks: a group lists the IPs and CIDRs whose devices belong to it, e.g.networks = ["192.168.2.0/24"]. A device gets the group of the most specific matching network.- An explicit
[[client]]entry withgroupsstill wins. - A
[[client]]entry withoutgroupskeeps its network's group: naming a device no longer moves it todefault. - Networks must not overlap at the same prefix length (validation error naming both groups).
- Groups without
listskeep using every list, so grouping for visibility never changes filtering unless the owner says so.
- An explicit
- Groups as a dimension everywhere:
- the dashboard gets a "Traffic by group" chart and a group filter for every widget (top domains, top blocked, top clients, latency);
- the query log gets a group filter and a colored group chip per row;
- the Groups page shows each group's networks, active devices, queries, block rate, and top domains and blocked names;
- anomaly findings name the group;
- the API gets
group=on stats, top, and query endpoints; - each group has a color, used consistently in charts and chips;
- metrics:
telltale_group_queries_total{group,status}(bounded: groups are capped at 64), next to the existingtelltale_blocked_total{group,list}.
- Replication: groups and networks are shared configuration (ADR-047), so every cluster node attributes devices the same way.
- Later: importing VLANs from a router (UniFi networks, OPNsense interfaces) into groups, with the router integrations (M8). The owner's UniFi connector makes this a natural follow-up.
Consequences
"which kinds of devices talk to what" becomes one click (e.g. IOT: 38% of queries, 21% blocked, top: amazon devices and Roku telemetry). Per-device names keep working inside each group. The owner's setup needs seven [[group]] entries in homelab-charts and nothing else.
Builds on ADR-047
Referenced by ADR-062
No decision matches. Try fewer words, or another status or area.