# Architecture

> **Related:** [Access Control](https://wavehouse.dev/access-control.md) · [API Reference](https://wavehouse.dev/api.md) · [Claude Code & AI agents](https://wavehouse.dev/claude-code.md) · [Configuration](https://wavehouse.dev/configuration.md) · [Deployment](https://wavehouse.dev/deployment.md) · [Development](https://wavehouse.dev/development.md) · [Durability & Storage](https://wavehouse.dev/durability.md) · [Getting Started](https://wavehouse.dev/getting-started.md) · [Ingest Pipeline](https://wavehouse.dev/ingest-pipeline.md) · [Named Pipes](https://wavehouse.dev/pipes.md) · [Behind a reverse proxy](https://wavehouse.dev/reverse-proxy.md) · [TypeScript SDK](https://wavehouse.dev/sdk.md) · [Settings Directory](https://wavehouse.dev/settings-directory.md) · [Why WaveHouse?](https://wavehouse.dev/why-wavehouse.md)
> **Also:** [HTML version](https://wavehouse.dev/architecture) · [Docs index](https://wavehouse.dev/llms.txt)

---
This document describes the internal architecture of WaveHouse, a schema-aware ClickHouse proxy.

## Overview

WaveHouse is a Go-based gateway that sits in front of ClickHouse, acting as the entry and exit point for data. It discovers your real ClickHouse table schemas, validates data at ingest time, batches inserts asynchronously, and provides real-time streaming and query caching.

```mermaid
flowchart TD
    Clients["Clients<br/>(REST API, SSE)"]:::client

    Clients --> IH
    Clients --> QH
    Clients --> SSH

    subgraph api["WaveHouse API Layer"]
        IH["Ingest Handler"] --> SR["Schema Registry"]
        SR --> DD["Dedupe (optional)"]
        DD --> MQ["MQ (NATS)"]
        MQ --> BC["Buffer Consumer<br/>(batch flush)"]
        BC -.->|rejected rows| DLQ["DLQ"]:::fail

        QH["Query Handler"] --> Cache["Cache<br/>(local or Redis + singleflight)"]

        SSH["SSE Handler"] --> Hub["Stream Hub<br/>(project once per role)"]

        SW["Active Sweeper"] -.->|purges old msgs| MQ

        NATS["NATS JetStream retains messages<br/>for SSE gap-fill<br/>via DeliverByStartTime"]
    end

    BC --> CH[("ClickHouse<br/>(analytics storage)")]:::store
    Cache --> CH

    style NATS fill:none,stroke-dasharray:5 5,stroke:#888,color:#888
```

## Binaries

WaveHouse ships a single binary, `wavehouse`: an all-in-one process running the API, batch worker, embedded NATS JetStream, and optional embedded Pebble dedup. The only external dependency is ClickHouse, unless a shared backend is selected: `cache.backend: redis`, `dedupe.backend: dynamodb`, or `mq.backend: nats`, which points the queue at a NATS cluster the operator runs and lets several processes, each running some of the [roles](/configuration#process-roles), share it. `cmd/wavehouse` is the shell — subcommand dispatch, the logger, `config.Load`, the signal context — and `internal/app` is the process itself (see [`app/`](#app--process-wiring) below).

## Internal Packages

```text
internal/
├── api/         HTTP layer (Chi router, handlers, middleware, the cached read paths' singleflight)
├── app/         Process wiring: build every component, run them under one errgroup, release in reverse
├── auth/        JWT/JWKS authentication middleware (HMAC or JWKS, role extraction)
├── cache/       Query cache: Ristretto L1 + the tenant-led version index; the Redis-compatible shared backend
├── chconn/      One ClickHouse pool per connection tuple among the served tenants, reconciled on reload under the ceiling
├── chsql/       Shared ClickHouse SQL helpers (identifier quoting, bind-safety)
├── config/      YAML + env var configuration loading
├── coord/       Leases for work that must run in one process at a time (the sweeper, the ingest processes' shard claim membership), with fencing tokens (the NATS KV implementation is internal/mq/lease.go)
├── dedupe/      Optional deduplication (Reserve/Commit/Release; Pebble, DynamoDB)
├── discovery/   ClickHouse schema introspection and validation
├── ingest/      Batch buffering, DLQ, Active Sweeper, and shard claims over a shared queue
├── keyenc/      The one escaping composite keys are built from (NATS subject tokens, cache keys, dedupe keys)
├── mq/          MQ boundary: the only NATS/JetStream importer (owned message/consumer/stream types + embedded server)
├── observability/ OpenTelemetry pipeline (traces/metrics/logs + Prometheus exposition)
├── pipes/       Named query pipes (NamedQuery type, parameter binding, Source)
├── policy/      Hasura-style access control (policy types, evaluation, Source)
├── query/       Structured query AST, SQL builder, and timestamp bucketing
├── settings/    Settings directory: validate the JSON files, hold each tenant's adopted snapshot, reload on watch / SIGHUP / API
├── stream/      SSE fan-out: event Hub (project once per role), Subscriber queue, Bucket fan-out, keepalive Heartbeater wheel
└── tenant/      Tenant id: the type, its grammar, the reserved default, the request header name
```

### `api/` — HTTP Layer

The API layer uses [Chi](https://github.com/go-chi/chi) for routing with RequestID, a CORS middleware that decorates each response from the allowlist of the tenant the request names (`corsOrigins`; the tenant-exempt routes and a refused request read tenant `0`'s, and nothing when no tenant `0` is served), and a custom JSON recoverer (`jsonRecoverer`) that emits a JSON `500` on panic instead of chi's plain-text `middleware.Recoverer`.

- **router.go** — Route definitions. Public: `/livez`, `/readyz`, and the content-free `/v1/health` SDK ping (plus the permanent `/healthz` alias and the deprecated `/health`, `/ready` aliases). Policy-gated: `/v1/ingest?table={table}`, `/v1/query?table={table}` (structured), `/v1/pipes/{name}` (named pipes), `/v1/stream`. Admin-only (`RequireAdmin` — role == `policy.admin_role`, or a request bearing the operator key's operator bit, which passes even under a nil policy; over a nested settings directory `NewRouter` mounts the gate with no policy at all, whatever `Dependencies.PolicySource` was wired, so the operator key alone passes): `/v1/ops/schema/*`, `/v1/ops/dlq/stats`, `GET /v1/ops/pipes[/{name}]`, `/v1/ops/settings/reload`, `/v1/ops/query` (raw SQL — same gate as the rest of `/v1/ops/*`). `NewOpsRouter` is the router of a process without the `api` role: the probes and their aliases, `/version` and the same-port metrics path (the part it shares with `NewRouter`, `newProbeRouter`), and `POST /v1/ops/settings/reload` behind `RequireAdmin(nil)`, so only the operator key passes; every other route is a 404, under `/v1/ops` only once that gate has passed.
- **auth middleware** — the JWT/JWKS authentication middleware is its own package, [`auth/`](#auth--authentication); the router runs it on every `/v1/*` route.
- **tenant.go** — `TenantMW` resolves the request's tenant ahead of the auth middleware on every `/v1` route outside `/v1/ops/*`: the [`X-Tenant-ID`](/deployment#multi-tenant-deployments) header (absent means `tenant.Default`), validated by `tenant.Parse` (`400`), looked up in the `settings.Registry` (`resolveStore`: `404` for an id it does not hold, a bare `503` for a tenant whose folder was rejected — the findings stay out of a body answered before authentication), and the resolved `*settings.Store` stored in the request context (`WithStore` / `StoreFromContext` — here rather than in `tenant/`, because `settings` names `tenant.ID`). A handler reads the store once and passes it down as an argument — the per-tenant getters it holds take it as a parameter (`(*settings.Store).Policy`, `.DedupeFor`, `.DefaultMaxRows`, … in production) — and nothing below a handler reads the context; a tenant route reached without a resolved store answers `500` rather than fall back to a tenant. The probes, `/version`, the metrics path, and `/v1/ops/*` are tenant-exempt; an ops route that addresses one tenant — the admin pipe reads, the schema routes, the raw-SQL proxy, the settings reload and the DLQ stats — names it in `?tenant=` (`opsTenant`, and `opsStore` over it for the routes that need the tenant's store; the DLQ stats need none, since the MQ holds the queue), parsed strictly so that a query `url.ParseQuery` would half-read is a `400` rather than a read of the default tenant, which is what it means when absent on the reads (on the reload, absent is the whole directory).
- **pipes.go** — Named query pipe handlers: admin listing (`GET /v1/ops/pipes[/{name}]`, read per request from its `pipes.Source`) and execution with parameter binding. A read is cached and coalesced; a write — bound SQL that `IsMutation` (`clickhouse_exec.go`) classifies as one — bypasses both and runs every call. `pipes.json` is the only way to define or change a pipe.
- **structured_query.go** — Handler for `POST /v1/query?table={table}`: validates query AST, enforces permissions, builds and executes SQL.
- **ch_errors.go** — `writeCHError`, the one mapping from a failed ClickHouse query to a response, shared by `/v1/query`, pipes and `/v1/ops/query` so they cannot drift apart: `chconn.Classify` decides the class, and the class the status, `code` and `retryable` ([ClickHouse errors on the query paths](/api#clickhouse-errors-on-the-query-paths)). A write pipe answers through `writeCHWriteError`, the same mapping with `retryable` always `false` and no `Retry-After`, since the write may have run.
- **ingest.go** — Accepts `POST /v1/ingest?table={table}` in three body shapes: one flat JSON object, a JSON array of them, or NDJSON. The **required** `Content-Type` chooses the format *family* — `application/json` versus the four NDJSON spellings — and within the JSON family the body's first non-whitespace byte picks array versus single object; the bytes never choose the family. Anything that is not exactly one readable media type is a `415`, decided before the body is read: the header is parsed per RFC 9110 §8.3, and because `Content-Type` is a singleton field, repeated header lines must all resolve to the same format and a value carrying a comma is refused unless the value as a whole parses as one media type — a comma inside a *quoted* parameter value is data, so `application/json; a=", application/x-ndjson; b="` is accepted. It then reads the whole (`MaxBytesReader`-capped) body into a pooled buffer and runs the per-format record readers over those bytes, so the `413` lands before any record is processed and peak memory per request is O(body) rather than O(record). Then it validates and encodes each record, and runs the records in windows of up to 256 (`ingestWindow`) through three phases: one dedupe `Reserve` for the window's ids, the publishes in record order (a deduped record under `mq.WithIdempotencyKey`, keyed by `dedupe.IdempotencyKey`), and one `Commit` of the published ids — a window is the unit of a dedupe round trip and of Pebble's commit `fsync`. An id another request holds answers `503` with the lease as `Retry-After`, a store that cannot answer (`dedupe.ErrUnavailable`) `503` with `Retry-After: 5`; a publish that fails at a record commits the ones before it and releases the rest, except that a failure other than `mq.ErrQueueFull` may have stored the event, so that record's claim is left to lapse and the idempotency key drops the retry's copy if it comes within the stream's two-minute duplicate window — `mq.ErrUnavailable` (a broker blip) is one such failure, and still answers `503`: with the lease, rounded up to whole seconds, as `Retry-After` when the failing record held a claim left to lapse, else the flat `Retry-After: 5`. Each row goes through `mq.Publisher` on `mq.Topic{Tenant, Table, Scope}` (the request's tenant, read off its resolved store — `store.Tenant()` — and raw names; the subject it becomes is `internal/mq`'s; a full queue comes back as `mq.ErrQueueFull`, which is the `503` + `Retry-After`). When dedup is on, a row missing the configured `id_field` (or setting it to `null`) can't be deduped: it is logged at `WARN` and counted by `wavehouse_ingest_dedupe_missing_id_total` (labeled by `table`), then published un-deduped — or rejected when `dedupe.require_id` is set ([#219](https://github.com/Wave-RF/WaveHouse/issues/219)).
- **query.go** — Proxies raw SQL for `POST /v1/ops/query` straight to the `?tenant=`'s ClickHouse HTTP interface (`chconn.Pools.Target` by the resolved store's tenant; the zero target — no pool — is a `503` with `Retry-After`). **Not cached** — sets `Cache-Control: no-store` so every request hits ClickHouse; DateTime is rendered ISO-8601 via `date_time_output_format=iso` (the Go-side type conversion lives in the structured-query / pipes path, not here).
- **stream.go** — Real-time streaming via SSE. Callers select a table with the `?table=` query parameter. Each connection registers one `Subscriber` (the `stream/` package) with both the event `Hub` (under its `(topic, role)`) and its tenant's keepalive wheel (`Heartbeater`, a lookup by the request's store; a tenant no longer served has none, and its streams are ending), then drains both from a single byte-pump — so idle streams keep emitting `:` keepalive comments (surviving reverse-proxy idle timeouts) while live events arrive already projected and serialized. Per-event projection/serialization happens **once per role** in the `Hub`, not once per subscriber ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)); the handler also snapshots the connection's JWT claims onto the `Subscriber`, which the `Hub` evaluates per subscriber when the role carries a row-level `filter` ([#319](https://github.com/Wave-RF/WaveHouse/issues/319)). Gap-fill replay (`mq.Replayer.ReplaySince` on the connection's `mq.Topic` — a `DeliverByStartTime` consumer inside `internal/mq`) stays per-connection (low-volume, one-time on connect). A stream ends, a gap-fill in progress included, when the server begins shutting down (`Closing`) or its `Subscriber` is evicted because its tenant is no longer served (`Hub.Prune`); one admitted just before the reload that stopped serving its tenant, and registered just after the prune, is ended right after it registers (`Served`).
- **schema.go** — Schema discovery API of one tenant, the `?tenant=` (`opsStore`): list all schemas, get one table, trigger refresh. `lookupSchema`, shared with the ingest and structured-query handlers, is the one reading of a `SchemaRegistry.Lookup` miss: `503` with `Retry-After` before the tenant's first discovery (`ErrNotLoaded`, or no registry built yet), `404` for a table the discovered schema lacks; the list answers the same `503` rather than `[]`. A refresh of a tenant on no pool (`discovery.ErrNoConnection`) is a `503` with `Retry-After` too. The handlers hold `RegistrySource`, `func(*settings.Store) *discovery.SchemaRegistry`, and the query paths a `func(*settings.Store) driver.Conn` beside it — each resolves the request's tenant per call, and a nil connection (a tenant no pool could be opened for, such as by the connection ceiling) is a `503` before a cached result is served or a query runs. The cached paths resolve it after their cache `Lookup`, so the snapshot predates the connection (see `cache.go` below).
- **dlq.go** — DLQ stats endpoint (`GET /v1/ops/dlq/stats`): asks `mq.DeadLetterStats.DeadLetterCounts` for one tenant's per-table parked counts (optionally one table) and its total — the tenant `?tenant=` names, read strictly by `opsTenant`, tenant `0` without it. The tenant is looked up in the MQ, not the settings registry, so a rejected or removed tenant's parked rows are read like a served one's; a tenant with no dead-letter queue (`mq.ErrNoDeadLetterQueue`) is a 404, and any other failure to read it a 500. The queue itself is `internal/mq`'s.
- **health.go** — Liveness (`/livez`), readiness (`/readyz`), and a content-free `Online` ping (`/v1/health`, the SDK's public liveness check); `/healthz` is a permanent alias of `/livez`, and `/health`/`/ready` are deprecated aliases. All three consult an optional `BootState` so they can return 503 while boot-time schema discovery is still failing in the retry loop (see `internal/app`; over a nested directory, while no tenant's has succeeded); once `BootState.Set(nil)` fires, `/livez` returns 200 and stays there. `/readyz` additionally runs a `Ping` each call — `chconn.Pools.Ping` in production: every open pool at once, ready at the first answer, every pool's error joined when none answers; `/v1/health` deliberately does not.

### `app/` — Process wiring

- **app.go** — `New(ctx, Options)` builds every component from the boot config (`Options.Config`) and the settings directory it names, in dependency order: settings registry, observability (after which each `Config.Warnings` line is logged at `WARN`), ClickHouse pools, schema discovery, the dedupe stores, the MQ (embedded NATS with its ingest + DLQ streams, or the external NATS), cache, the lease coordinator, sweeper, streaming (hub, MQ→hub bridge, keepalive wheels), ingest worker, auth, reload triggers, HTTP. The boot config's `roles` decide which of them a process wires: every process gets the settings registry, observability, the MQ, the coordinator, the reload triggers and a listener; `api` adds schema discovery, the dedupe stores, streaming, auth and the full router; `ingest` adds the ingest worker; `sweeper` adds the sweeper on `mq.backend: embedded` only (under `nats` the streams' retention replaces it); the ClickHouse pools and the cache come with `api` or `ingest`. A process without `api` serves `api.NewOpsRouter` (probes, `/version`, the metrics path, and the settings reload behind the operator key alone, `wireOpsAuth`) on `server.port`. `config.Validate` refuses a role set the backends cannot serve (a split over the embedded MQ, ingest on a shared MQ over a local coordinator, a sweeper-only process on a shared MQ, or `api` without `ingest` and the reverse over a local cache), and `New` refuses a `Config` with no roles, which only one built without `config.Load` can have. Each is one `component` value — what it opens, what it loops, what it releases — so a failure part-way releases what was already opened and returns the error. `Run(ctx)` drives every loop under one `errgroup` until `ctx` is canceled (a clean stop: every loop drains, the API server and the ingest worker within `server.shutdown_timeout`; open SSE streams are ended as the drain begins rather than waited on) or a component fails, which stops the rest and returns that error. `Close(ctx)` releases what `New` opened, newest first, under the caller's release budget (`ReleaseTimeout`, 5s), a real bound: a remote implementation's close gives up at the deadline itself, and a close that ignores the context (the local stores) is abandoned at it, with the components below it left unreleased rather than overlapping it, both named in the error — and then flushes telemetry under its own 3s budget, so the flush that reports on the stop is never handed a deadline a slow close already spent. The SIGHUP registration is released last of all. `Handler`, `Registry`, and `MQ` expose the pieces a harness needs; `Options.Listener` lets one serve the API on its own listener instead of `server.port`.
- **wire.go** — one `wire*` function per component, each handed the settings registry whole and deriving the per-call getters the internal packages take (`DLQFor`, `DedupeFor`, `GapWindow`, …) and registering its `AfterAdopt` hook there where it has one. A layer with a choice of implementation — `wireMQ`, `wireCache`, `wireDedupe`, `wireCoord` — picks it there and nowhere else, in a `switch` on the boot config's `<layer>.backend` with one case per backend; the default case refuses boot, which only a `config.Config` built without `config.Load` reaches, since `Validate` refuses a value no case handles. `wireCache` has two: `local`, the in-process `LocalCache`, and `redis`, the shared `RedisCache` built from the `cache.redis` block, which boots bypassed rather than failing when its server is unreachable. Those wiring functions are where the per-tenant registry of [#583](https://github.com/Wave-RF/WaveHouse/issues/583) is injected, not `main`: `wireSettings` opens the `settings.Registry`, the HTTP handlers get store-keyed getters (method expressions such as `(*settings.Store).Policy`), and `perTenant` adapts a store accessor into the `func(tenant.ID) T` getter the async packages take, with the tenant each message's `mq.Topic` names for the stream hub and the ingest worker — a tenant the registry is not serving is logged and read as the zero value, except in `dlqFor`, the ingest worker's DLQ switch, where it reads as on so a message the worker cannot read is parked rather than dropped, and a removed or rejected tenant's queued rows are parked rather than left unacked, where each would be redelivered every ack wait for as long as the tenant is away and would hold that tenant's ack floor, so the sweeper could purge none of its queue past it. The ClickHouse pools (`chconn.Pools`) and the per-tenant schema registries (`discoveries`, in `discoveries.go`) are reconciled from `AfterAdopt` after every reload ([#583](https://github.com/Wave-RF/WaveHouse/issues/583) story 6): `wireClickHouse` builds each served tenant's `chconn.Member` from its store and logs what the reconcile refused; `wireDiscovery` builds a registry over `pools.For` for each newly served tenant — a flat directory's tenant `0` refreshed synchronously first, as before — runs its loop under the App's stop context, stops the loop of a tenant no longer served, and drives the `BootState` from the first tenant's first discovery, sticky from there; before that, a diagnostic naming a tenant a reload stopped serving goes back to the no-tenant one. The handlers resolve both per request through store-keyed getters (`chConnFor`, `registryFor`, `chTargetFor`, `queryTimeout`), the hub and the ingest worker through tenant-keyed ones (`discoveries.For`, `pools.Target`) called with the tenant the message's topic names; a tenant on no pool is an untyped nil connection, the handlers' `503`. The ingest worker is handed the cache through `sharedTables`, which bumps each namespace the worker invalidates under every tenant on the same ClickHouse address and database (`pools.SharingTables`), and the pools hook orphans the whole cache — structured-query and pipe results — of a tenant back on a pool after an absence (`Cache.InvalidateTenant`), since it was out of that fan-out while away, and of a tenant moved to another address or database, since it now reads other tables (both returned by `Pools.Reconcile`). The one setting that still follows the default tenant is read per request, the admin role of a flat directory's ops gate: `defaultPolicy` reads it through the registry, where a flat directory's tenant `0` is always served. The auth verifiers are per tenant: `wireAuth` builds one for each tenant being served, its `AfterAdopt` hook reconfigures the adopted tenants' (rebuilt only when their wiring changed) and prunes the ones no longer served, and the operator key's admin role is read from the request tenant's policy. `wireStreaming`'s hook prunes the stream hub the same way (`Hub.Prune`, with the one `served` predicate the auth, dedupe and cache hooks use too), ending the open streams of a tenant no longer served, and `wireCache`'s hook prunes the cache's version index the same way (`LocalCache.Prune`, [#262](https://github.com/Wave-RF/WaveHouse/issues/262)), so a tenant no longer served stops holding it. The keepalive wheels are per tenant as well (`keepalives`, in `keepalives.go`, [#597](https://github.com/Wave-RF/WaveHouse/issues/597)): a second `wireStreaming` hook gives each served tenant a `stream.Heartbeater` in the shape of its own `stream.keepalive_interval` and `stream.keepalive_buckets`, reshapes a served tenant's in place (`Reconfigure`, a no-op when the pair did not change, so one tenant's reload never moves another's cadence), and stops the wheel of a tenant no longer served before the reload returns. The `keepalive` component's loop turns the wheels for as long as `Run` does, and returns once each has stopped; one built before `Run` holds its streams until then. The stream handler asks for its request tenant's wheel through a store-keyed getter (`keepaliveFor`), and a lookup that lands after a reload adopted a tenant and before the hook ran reconciles first, so a served tenant always has a wheel. The ingest worker's queue, over a `mq.Sharded` broker (`mq.backend: nats`), is wrapped in `ingest.ClaimShards` over the same coordinator, so each process consumes only its share of the shards; the `nats` coordinator holds the ingest processes' membership leases there, since no sweeper is wired under `nats`. The sweeper is handed each tenant's own `stream.gap_window_minutes` (`gapWindows`, read every sweep over `Registry.Known`, so a rejected tenant keeps the window its folder last had, and all of its history if the folder has been rejected since boot), since each tenant's events have a queue of their own, and runs only while its process holds the `sweeper` lease (`elected`, which wraps `coord.RunElected` over the coordinator `wireCoord` opens: `local` keeps leases in the process, so the one process always holds it; `nats` calls `ExternalNATS.Leases` on the MQ's own connection with the bucket `coordBucket` names — `coord.nats.bucket`, or `mq.DefaultNATSCoordBucket` of the subject prefix — and `instance_id` as the holder. `wireNATSMQ` hands the same bucket name to the topology, so boot waits for it with the streams. The coordinator is added after the MQ, so it closes first and resigns its terms while the connection is still up). The dedupe stores are per tenant ([#583](https://github.com/Wave-RF/WaveHouse/issues/583) story 7): `wireDedupe`'s `pebble` case builds a `dedupe.Stores` over the `Tenant` factory of the embedded Pebble implementation (`dedupe.NewEmbedded`), handing it `data_dir` once; the implementation decides where every tenant's store lives — one instance, each key led by its tenant (story 3) and then its table — and one reconcile closure, the boot apply and the `AfterAdopt` hook alike, sets every store to what the registry says: open exactly when its tenant is served with `dedupe.enabled` on, closed with its seen ids kept when the tenant is switched off, rejected, or removed. An instance that cannot open follows the registry's rule for the shape: fatal at boot over a flat directory, fail-closed for every tenant with dedupe on over a nested one. The system gauges report that one instance's figures (`Embedded.Stats`), not a sum over tenants. `wireDedupe`'s `dynamodb` case is `wireDynamoDedupe`, in wire_dynamodb.go (below). `wireHTTP` hands the ingest handler `dedupe.lease` (`IngestHandler.DedupeLease`) whichever backend is chosen. The ingest handler picks the tenant's store off the request's `settings.Store` (`Store.Tenant()`). The reload triggers only start in `Run`, after `New` has registered every hook, so the watcher's first reload already drives all of them: SIGHUP in both shapes, the directory watcher for a flat directory only. `wireMQ`'s `nats` case builds an `mq.NATSConfig` from the boot config's `mq.nats` block and calls `mq.NewNATS`, which waits for the operator's topology under `New`'s context; it hands over no budget, since the operator's streams set every limit. Both cases end in `adoptMQ`, which registers the MQ's close and the system gauges. The `embedded` case hands each served tenant's `mq.max_bytes_gb` to `mq.Broker.SetMaxBytes` at boot, under `New`'s context (so a stop signaled mid-boot is not held up by opening many queues), and again after every reload, under the App's stop context; the first apply opens that tenant's queue. A queue that cannot be opened or resized follows the registry's rule for the shape — fatal at boot over a flat directory, logged over a nested one — and is retried by the next reload, a queue that did not open by the next publish too. How the budget is split across the tenant's streams, the time bounds, the rollback, and the dead-letter shrink guard are `internal/mq`'s.

- **wire_dynamodb.go** — `wireDedupe`'s `dynamodb` case, split out of wire.go so the e2e suite's coverage exclude for it (the e2e binary always runs Pebble dedupe, never DynamoDB) doesn't have to blanket wire.go itself: builds the same `dedupe.Stores` over `Dynamo.Tenant`, gated (`Factory.Gated`) on the table's check: boot runs `Dynamo.Check` (after `CreateTable`, when `dedupe.dynamodb.create_table` is on) whether or not any tenant has dedupe on. Boot is refused only for a misconfigured table (an error that is not `ErrUnavailable`) over a flat directory whose tenant has dedupe on; every other failure boots with the switched-on stores closed, the check retried until it passes by a background component that backs off from one second to thirty (a nested directory has no watcher, and a flat one's table can come good with no settings change). The `AfterAdopt` hook never runs the check, since it holds the lock that serializes reloads, and it does not wait on a tenant whose `dedupe.enabled` is unchanged either — `Managed.Apply`'s no-op fast path settles that case under its own read lock, so the hook only takes a store's write lock, and so waits for that tenant's in-flight `Reserve`/`Commit`/`Release` calls to finish, on a genuine flip. It applies every store against the last check's result, so a tenant a reload switches on fails closed meanwhile, and wakes the retry, so a reload still retries at once. It has no Pebble gauges.
- **wire_nats.go** — the `nats` cases of `wireMQ` and `wireCoord` (`wireNATSMQ`, `wireNATSCoord`, and the lease bucket's name, `coordBucket`), split out of wire.go for the same reason as `wire_dynamodb.go`: the e2e binary never runs `mq.backend: nats`. `wireNATSMQ` hands the topology check the dedupe lease and, in a process running `api`, warns at boot and after every reload about a tenant with dedupe on whose finite retention is under the partitions' duplicate window (`ExternalNATS.DuplicateWindow`), and runs `ExternalNATS.CheckReplayWindows(servedGapWindows(...))` at boot and from an `AfterAdopt` hook, the check of a gap window longer than the history keeps that the sweeper made on the embedded MQ.
- **wire_ops.go** — the ops-only listener of a process without the `api` role (`wireOpsAuth`, `wireOpsHTTP`), split out of wire.go for the same reason: the e2e binary runs every role.

### `stream/` — SSE keepalive & fan-out

The SSE fan-out, factored out of `api/` so the delivery hot path ([#294](https://github.com/Wave-RF/WaveHouse/issues/294)) lives next to the keepalive primitives it shares. One abstraction per file.

- **hub.go** — `Hub`, the event fan-out. Subscribers register under `(mq.Topic, role)` — one tenant's table, so a subscriber never receives another tenant's rows for a table of the same name — and each event is evaluated under its own tenant's policy (the `PolicySource` read with the topic's tenant; a gap-fill and the opening schema frame read the connection's); `Broadcast` decodes each event once, applies each subscribed role's column policy once, builds one SSE frame per role, and fans it to every member of that role's `Bucket` — prepending a per-connection `event: schema` frame wherever that connection's announced column list has drifted, and withholding the row if the announcement cannot be queued — collapsing the prior per-subscriber `unmarshal → evaluate → filter → marshal` into one pass per distinct `(role, table)` output shape (the [#294](https://github.com/Wave-RF/WaveHouse/issues/294) lever; the measured ceiling was ~2 270 deliveries/s from re-projecting per subscriber). That schema-before-row guarantee is the LIVE path's: `ReplayProjector` tracks drift in its own state and the two are not reconciled ([#543](https://github.com/Wave-RF/WaveHouse/issues/543)). The column projection is claims-independent, so it is shared across a role's whole bucket; the role's row-level `filter` predicate is not — it is resolved against each subscriber's JWT claims, so for a role that carries a filter `Broadcast` keeps the shared column projection but delivers it only to the subscribers whose claims admit each row (`ResolvedPermissions.RowVisible`, evaluated against the full event via the type-aware comparison seeded from the schema registry — `policy.ColumnSpec`: numeric columns compare numerically, `String` bytewise, `DateTime`/`DateTime64` as instants through the same parser ingest canonicalization uses (`discovery.Column.TimeParser` — one grammar, so filter constants and canonicalized payloads can't disagree on the instant), everything else admits byte-equality only and fails ordering/`!=` closed, so a missing schema can never downgrade the comparison to a leak). Each row withheld this way increments `wavehouse_sse_rows_withheld_total`. This is the [#319](https://github.com/Wave-RF/WaveHouse/issues/319) fix that closes the query/stream row-level-security drift; roles without a filter keep the pure once-per-role fast path. `ReplayProjector` shares the same projection and per-connection row check for the handler's gap-fill, reading the policy per replayed event as `Broadcast` does, and caching the per-table column-kind lookup across the replay loop. `Prune(served)` evicts the subscribers of every tenant a reload stopped serving, removed or rejected alike, so their streams end rather than outlive the tenant with every row withheld.
- **subscriber.go** — `Subscriber`, the per-connection handle. It carries the connection's JWT claims, fixed at construction (`NewSubscriber(claims, metrics)`, no setter) — the claims the `Hub` resolves a role's row-level `filter` against, and immutability is what makes the fan-out's unsynchronized claims read race-free structurally. It owns a single ready-to-write outbound queue of `Frame`s (each tagged with its `kind`, so the handler labels the write where it happens): producers — the keepalive wheel and the event `Hub` — fan frames in with `Send` (non-blocking; a full queue drops, and `Send` itself counts the drop by frame kind, so no producer can forget to), and the handler drains `Frames()` to the client verbatim. The queue is sized for buffering live events (cap 64, up from the keepalive-only cap 1; #152 will make it a knob), and `Evict` closes its `Evicted()` channel, once, for the handler to end the stream: the `Hub`'s `Prune` does for a tenant no longer served, and the slow-consumer follow-up will for a wedged consumer.
- **bucket.go** — `Bucket`, the reusable fan-out primitive: a concurrency-safe set of subscribers. `Push` fans one `Frame` to every member fire-and-forget — the keepalive wheel's ring is its only caller now that both `Hub` paths iterate `Snapshot`, since the schema announcement is per connection even where the projection is shared per role; `Snapshot` exposes the members so the event `Hub` can evaluate row visibility per subscriber before sending (drop counting lives in `Send` itself). The `Hub` holds one `Bucket` per `(topic, role)` so a projected frame is built once and sent to every member instead of re-projected per subscriber.
- **heartbeat.go** — The keepalive wheel (`Heartbeater`), one per served tenant (`internal/app`'s `keepalives`). Its single ticker fans a minimal `:` comment across the ring of `Bucket`s, waking ~1/N of the tenant's live streams per tick so the writes don't synchronize. The effective per-connection keepalive period is the tenant's `stream.keepalive_interval` in the settings directory (the wheel ticks every `keepalive_interval ÷ keepalive_buckets`, so one rotation spans the interval; a reload of that tenant's settings calls `Reconfigure`, which rebuilds the ring in place with every live subscriber carried over); the owning handler goroutine does the actual write, so the wheel's ticker never touches a `ResponseWriter` directly.
- **metrics.go** — `Metrics`, the SSE instrument set: `wavehouse_sse_active_streams` (open streams), `wavehouse_sse_stream_duration_seconds` (lifetime), `wavehouse_sse_frames_sent_total` / `wavehouse_sse_bytes_sent_total` (labeled by `kind`: `keepalive`, `event`, `replay`, `schema`), `wavehouse_sse_dropped_frames_total` (frames dropped to a full subscriber queue — the slow-consumer signal that was silent before #294), and `wavehouse_sse_rows_withheld_total` (rows withheld from a subscriber by the row-level-security filter, labeled by table and role — the signal that separates "no matching rows" from "a fail-closed filter is withholding everything"). Nil-safe, so the handler holds one unconditionally and tests skip wiring it; one shared instance records the handler's write sites, each `Subscriber`'s queue-full drops (counted inside `Send`, by frame kind), and the `Hub`'s row-withheld counts. Separate from `observability.RegisterSystemMetrics`, which covers only the NATS/Pebble system gauges. Streams are observed through these metrics rather than per-event traces (the router excludes `/v1/stream` from the HTTP tracer).

### `auth/` — Authentication

- **auth.go** — `NewAuthenticator(cfg, tenantOf, policies)` owns one verifier per tenant: `cfg` is the boot-config half (the operator key, shared by every tenant), and `Reconfigure(id, wiring)` gives a tenant a verifier built from its own `auth` block (`auth.jwks_url` / `auth.jwt_secret` / `auth.role_claim` — the HMAC secret is the tenant's own, so a reload that rotates it rebuilds the verifier; see [Settings Directory — Authentication](/settings-directory#authentication)) — key source plus its pinned `alg` allowlist, swapped atomically after a reload that adopts the tenant and kept when the wiring did not change; `Prune` drops the verifiers of tenants that stopped being served (removed or rejected), `Close` stops every JWKS refresh. `Middleware()` reads the request tenant's verifier per request — the tenant of the store `TenantMW` resolved (`settings.Store.Tenant()`, through the injected `TenantSource`), tenant `0` on a tenant-exempt route — so a JWKS-issued token verifies only under the tenants whose `jwks_url` names its provider's key set; a tenant with no verifier fails closed. A JWKS key set is fetched off the calling goroutine, so neither boot nor a reload waits on the endpoint: the verifier is in place at once and *pending* until a set has been stored (`observedKeys`, the one success signal the library gives — a failed first fetch leaves it holding an empty set). That first fetch is `fetch`'s to retry, from one second doubling to a minute, since a pending verifier never reaches the library and so never triggers its unknown-key-id refetch; once a set is stored the library keeps it fresh on its own, hourly and, rate-limited, on an unknown key id. Each response is capped at 1 MiB. A token checked against a pending verifier records `auth.ErrVerifierPending`, which the router's `refuseUnverifiable` turns into `503` + `Retry-After: 30` on every `/v1` route rather than let the request run under the `default_role`. Verifies JWT tokens with HMAC **or** JWKS (never both), with the accepted `alg` pinned to the active verifier and checked before any key is consulted (rejects `alg: none` and cross-family confusion). Extracts the caller's role from a configurable dot-path claim (`auth.role_claim`, default `role`). Claims parse with `jwt.WithJSONNumber()`, so a numeric claim reaches the policy engine as its exact digits (`json.Number`, never a rounded float64) — part of the row-visibility guarantee (AGENTS.md invariant 12). It always runs and never rejects — a missing/invalid/expired token yields an empty role (resolved to `default_role` downstream), with the token error stashed in context so a denying gate can fail loud (`401`, not a bare `403`). Before the Bearer token it checks a non-JWT operator key (`auth.operator_key`): a constant-time match on the presented credential — an `Authorization: Operator <key>` header, or the `X-Operator-Key` alias — stamps the live admin role — the request tenant's, read from that tenant's policy (`policies`) — plus an operator bit (`auth.WithOperator`) that `RequireAdmin` honors even under a nil policy — a full-access break-glass credential, audit-logged at Info with no client IP (the audit line goes through the default logger). A presented-but-wrong operator key is logged at `WARN` and counted by `wavehouse_auth_operator_key_failures_total` — a probing signal on the most privileged credential — then falls through like any unauthenticated request (the middleware never rejects).
- **context.go** — request-context accessors and their setters for the role, claims, and token error (`RoleFromContext`, `ClaimsFromContext`, `AuthErrorFromContext`, and the matching `With*` helpers).

### `cache/` — Query Cache

- **cache.go** — `Cache` interface: `Lookup`, `Set`, `Invalidate`, `InvalidateTenant`, `Close`, plus `QueryTimeToTTL`, which sets a result's TTL from how long its query took (10 s floor, 1 h ceiling). Every entry is one tenant's: `Lookup` takes the tenant, the caller's query key — `<tenant>:query:<sha256 of SQL + params>`, built by the two cached handlers in `api/` (`queryCacheKey`, with the tenant read off the request's store — `settings.Store.Tenant`), which use it as their [singleflight](https://pkg.go.dev/golang.org/x/sync/singleflight) key too — and the `Namespace`s the result depends on — one for a structured query, none yet for a pipe (a pipe's table dependencies are [#343](https://github.com/Wave-RF/WaveHouse/pull/343)) — each of that tenant (another tenant's is `ErrForeignDependency`) and naming a table and scope by their raw names, which the cache escapes where it builds a key. `Lookup` returns the `Entry` (a nil value is a miss) and a `Snapshot` of the versions it read; on a miss the handler runs the query and passes that snapshot to `Set`, so a result is filed under the versions read *before* its query ran, and a write that lands while it runs orphans the fill rather than re-homing pre-write rows under the post-write versions ([#382](https://github.com/Wave-RF/WaveHouse/issues/382)). The snapshot is taken before any input a bump invalidates is chosen, the tenant's connection included: a reload that moves the tenant to another address or database runs `Pools.Reconcile` and then `InvalidateTenant` (a repoint that keeps both, such as a username or `tls` change, reads the same tables and bumps nothing), so a request that took the old pool files the old database's rows under a version that bump orphans, whether its `Set` lands before the bump or after. The singleflight leader's snapshot is the one used. `Set` returns an error only when the backend failed; a value the cache declines — larger than it keeps, a non-positive TTL, a zero snapshot — is not one. What a hit, a miss and a bump mean is pinned by the conformance suite every backend runs, `internal/testutil/cachetest`.
- **local.go** — `LocalCache`, the in-process L1 on [Ristretto](https://github.com/dgraph-io/ristretto): one pool shared by every tenant (a heavier tenant holds more of it), sized by the boot config's `cache.l1_max_cost`.
- **version_manager.go** — `VersionManager`, the invalidation index behind `Invalidate` and `InvalidateTenant`: one version per tenant, per (tenant, table) and per (tenant, table, scope), each keyed by its name alone and bumped in place, so the index holds one entry per live tenant, table and scope however often each is bumped ([#262](https://github.com/Wave-RF/WaveHouse/issues/262)). A query key folds the tenant's version and, for each dependency, its tenant's, table's and scope's, so bumping a table (a scopeless write) orphans every scope of it, and bumping one scope orphans that scope and the whole-table view — scope is reserved and empty today, so every write is the whole-table bump — all without touching the pool. Every field — the caller's query key, the tenant id, and each dependency's table and scope — is escaped and joined by `internal/keyenc` where the key is built, so a dot, a space or a `%` in a name is never read as a separator: each dependency renders as `<tenant>.<gen>.<table>.<table version>.<scope>.<scope version>`, and the whole entry key is `<query key>|<tenant>.<gen>|<dependency>|…`. The tenant leads every key ([#583](https://github.com/Wave-RF/WaveHouse/issues/583) story 8): the same table under two tenants is two namespaces, so a bump through `Invalidate` under one tenant never touches — and a read under one tenant is never served — the other's results. A tenant's version is a *generation*, unique within the process and handed out by the first key built for the tenant; `BumpTenant` (behind `InvalidateTenant`) drops the tenant's whole index, so the next key gets a fresh generation no cached entry folds, orphaning every cached result of the tenant in one step — a pipe result with no dependencies, and a table no bump ever keyed, included — for a tenant back on a pool after an absence from the fan-out, or moved to another address or database (story 6). `LocalCache.Prune` does the same for every tenant no longer served, which `internal/app` runs after each settings reload, so a tenant removed or rejected stops holding its index. A table bump drops the table's scope versions with it, since every key they were folded into also folds the old table version; and any bump (of a table, a scope or the tenant) under a tenant with no index is a no-op that records nothing, since the next key built for it gets a fresh generation no cached entry folds — so neither the `sharedTables` fan-out nor an insert still in flight for a tenant just pruned brings its index back. The index is per tenant; the cross-tenant invalidation an insert into a shared table needs is not the index's but the wiring's: `internal/app` hands the ingest worker a cache (`sharedTables`) that repeats each bump under every tenant on the same ClickHouse address and database.
- **redis.go** — `RedisCache`, the shared backend: one Redis-compatible server (Redis, Valkey, Dragonfly, ElastiCache, MemoryDB — only `GET`, `SET` and `MGET`, no scripts, no client tracking) holds every process's results and versions, so a bump one process makes orphans what every process cached. Versions are random 8-byte tokens in a flat key space, one per tenant (`<prefix>:{t}:T`), table (`:B:<table>`) and scope (`:S:<table>:<scope>`, the empty scope being the whole-table view), all under the tenant's hash tag so they share one cluster slot; the table and scope are escaped with `keyenc` after the fixed prefix, so a `:` in a name is never read as the separator. A dependency folds three: the tenant's, its table's and its scope's; a scopeless write bumps `B:<table>`, a scoped one `S:<table>:<scope>` and `S:<table>:`, `InvalidateTenant` the tenant's — the lattice `VersionManager` encodes. `Lookup` pipelines an `MGET` of the tokens with a `GET` of the value in one round trip; the value (`<prefix>:q:<t>:<hash of sha and deps>`, the hash over their escaped forms, no hash tag, so a tenant's values spread across shards) carries the tokens it was filed under and is a hit only while they are all current. A missing token is created (`SET NX`) and read back, never read as a value, and a token key holding anything but a token (a string of another length, a list, a hash) is replaced, so a token lost to eviction, expiry, `FLUSHALL` or a restart without persistence is a miss for everything under it, never a revival — any `maxmemory-policy` that evicts is safe. Restoring an RDB or AOF snapshot, or a backup, is not a loss but a rollback — a restart after a crash that reloads the server's last save included, which stock Redis and Valkey make by default: the old tokens come back with the values filed under them, so what was invalidated since is served again until its TTL, as after a failover to a replica that missed the bumps. A failure or a timeout past `Timeout` (100 ms) is a miss, a skipped fill and a deferred invalidation; queries never fail on the cache. While this process owes a bump on any of a lookup's tokens, that lookup is a bypass that files nothing: the bump would orphan whatever it found (other processes, which cannot know, serve those entries until it lands). `NewRedis` never fails on an unreachable server: the cache starts bypassed and keeps dialing, each attempt bounded by `DialTimeout` (1 s), and a cluster client's topology read after the handshake by the larger of it and `Timeout`, which is also how long a connection waits on a silent server before it is redialed. Every connection is replaced after a minute (`clientOption`; rueidis retries what was in flight), because one that outlives a failover behind a stable address stays on the demoted node, which answers but refuses writes; the replacement re-resolves the address, so a bypassed process reaches the new primary, and delivers the bumps it owes, within about that long. rueidis dials a replacement lazily, under the context of the operation that lands on it, bounding the dial (TLS included) by `DialTimeout` and then the handshake by `DialTimeout` again, so a reconnect slower than `Timeout` fails that operation. The breaker's probe gives its write twice `DialTimeout` for a reconnect on top of `Timeout`, so such a reconnect still closes an open breaker. The allowance is for a reconnect only: a probe write slower than `Timeout` is repeated under `Timeout`, and the repeat decides, so a server answering slower than `Timeout` stays bypassed. But rueidis spreads commands over several connections (up to four to one server, by `GOMAXPROCS`, and one per cluster node) and the probe reconnects only the one it lands on, so size `Timeout` above a reconnect, or operations that land on the others keep failing. `cache.backend: redis` selects it: `internal/app`'s `wireCache` maps the boot config's `cache.redis` block onto `RedisConfig`, reading the TLS files, and releases it with the other components.
- **redis_codec.go** — the key schema (`tokenKeys`, `bumpKeys`, `valueKey`, which take a `Namespace`'s raw names and escape them) and the value frame: format, flags, expiry, the token list, the payload, zstd-compressed from `CompressMinBytes` when that is smaller. A stored value is capped at `MaxValueBytes` and a decoded one at eight times that, which refuses a zip bomb planted in a shared server; an unknown format, as a newer process writes during a rolling upgrade, is a miss.
- **breaker.go** — the circuit breaker's state machine: `BreakerThreshold` (5) consecutive failures open it, `trip` opens it at once, and while it is open every operation skips the server; once `BreakerOpenFor` (5 s) has passed, `allow` hands one caller the probe, and only the probe's success closes it. What feeds it is `redis.go`'s. `record` counts a transport failure or a timeout against the server, and trips the breaker on an error reply that means no bump can land: one saying the server takes no writes right now, as `refusesWork` lists them — `READONLY` (a demoted primary), `MASTERDOWN`, `OOM` (full under `maxmemory-policy noeviction`, Redis's default, so give the cache an evicting policy), `NOREPLICAS`, `MISCONF`, `LOADING`, `BUSY`, `CLUSTERDOWN` — or one refusing the credentials, as `rejectsCredentials` lists them — `WRONGPASS`, `NOAUTH`, what a new connection's handshake meets after a password rotation. A closed breaker opening is logged once, at `ERROR` for the credentials and `WARN` otherwise, not once per operation in flight; a failed probe opens it afresh and logs at `DEBUG` (`logOpening`), unless its cause differs from the one last logged, which is logged at its own level; so a server that stays down logs one `WARN` or `ERROR` for the whole outage. Any other reply, an error reply about one key or command (`WRONGTYPE`, `NOPERM`, `TRYAGAIN`) or one the backend cannot use included, counts as a success, and a caller that gave up first counts as nothing. The probe (`probe`) is a write, `SET <prefix>:probe`, so a server that answers but refuses writes stays bypassed.
- **pending.go** — the invalidations owed (`pendingBumps`): kept per token key, repeats coalescing, and past `PendingMax` (100,000) keys collapsed to one tenant bump per affected tenant; `owesAny` tells `Lookup` which lookups to hold. `redis.go` delivers them: `Invalidate` sends its bumps 1,000 to a round trip and defers those the server does not take, and `drainLoop` retries them through `drain` until they land — the first one owed at once, then with backoff from 100 ms to 10 s, and while the breaker is open at each probe, which the loop starts when due, so a process that makes no lookups (ingest only) recovers as soon as one that does. Landing late is still correct: a fresh token orphans the pre-write entries and any fill made meanwhile. `Close` keeps retrying the bumps still owed, past the breaker (an open one is why they are owed), every 100 ms for up to 1 s; what it cannot deliver in that budget is lost, and the entries they would orphan are served until their TTL — the same failure as a worker stopping between an insert and its invalidation.
- **metrics.go** — the shared backend's instruments (meter `wavehouse-cache`, `backend="redis"`, no tenant attribute): `wavehouse_cache_lookups_total` by `result` (`hit`, `miss`, `stale` — filed under since-bumped tokens, `bypass` — server skipped, or held by a bump this process owes, `error`), `wavehouse_cache_op_duration_seconds` by `op` (`lookup`, `set`, `invalidate`), `wavehouse_cache_breaker_open` (1 while bypassed, including before the first connection), `wavehouse_cache_invalidations_total` by `result` (`ok` counts every bump that lands, retried ones included, and `deferred` each bump an invalidation could not deliver when made, a repeat of one already owed included; a failed retry is not counted again, so the two overlap), `wavehouse_cache_invalidations_pending` (bumps owed: entries they would orphan may be served stale meanwhile), `wavehouse_cache_value_bytes` (stored size), `wavehouse_cache_oversize_total` and `wavehouse_cache_set_failures_total` by `reason` (`oom`, `timeout`, `other`).
- **cachetest** (`internal/testutil/cachetest`) — `Run(t, factory, Options)`, the conformance suite every `Cache` backend runs, each case on a fresh cache from the factory: what a hit, a miss and each kind of bump mean, independent of where entries and versions live. `Options` describes what a backend can do beyond the `Cache` contract, and leaving one unset skips the cases it enables: `MaxValueBytes` opens the oversize case; `NewPair` returns two instances over one shared store, for the cross-instance cases a shared backend must run; `Entries` counts a cache's entries, and without it the zero-snapshot case — no `Lookup` reads the key a zero `Snapshot` would land under, so only a count shows that one stored nothing — is skipped.

### `config/` — Configuration

- **config.go** — Loads *boot* configuration from a YAML file with environment variable overrides (using [cleanenv](https://github.com/ilyakaznacheev/cleanenv)); every key has a `WH_`-prefixed env var. Boot config is only what can't change under a running process — the implementation each layer runs on, the process's `roles`, resource sizing, listeners, observability exporters, the settings-directory path, and the platform's own secrets (`cache.redis.password`, `auth.operator_key`). Everything tenant-tunable lives in the settings directory (`settings/`), a tenant's secrets included: `clickhouse.password` beside its user and `auth.jwt_secret` beside its verifier wiring, so each reloads with what it belongs to ([#529](https://github.com/Wave-RF/WaveHouse/issues/529)). Both sources are strict: `Load` refuses to boot naming every YAML key the struct doesn't declare (`strict.go`) and every `WH_*` environment variable no field binds (`check.go`), so a tunable that moved to the settings directory can't be read, ignored, and believed. Boot is the validator for this half — there is no dry-run command. See [Configuration Reference](/configuration).
- **check.go** — `rejectUnboundEnv` is the environment half of the strict loader: `unboundEnv` walks the struct's `env` tags (plus the two process-level names, `WH_CONFIG` and `WH_LOG_LEVEL`) against the environment; `CheckDataDir` probes `data_dir` — run by `main` right after `Load` when `Config.NeedsDataDir` says a selected backend keeps state there, so an unusable `data_dir` refuses boot before anything dials out. It refuses an empty value (reachable through `WH_DATA_DIR=`) outright rather than probing the working directory; a path that exists and is not a directory; a dangling symlink at `data_dir` or any component above it (the walk to the nearest existing ancestor uses `Lstat`, so a failed mount is not skipped over as "does not exist"); and a directory the process cannot write to — or, when it does not exist, an unwritable nearest ancestor — probed by creating and removing one temp file. A permission denial, on the probe or on reaching the path through a parent without search permission, carries the UID-65532 hint, since a bind mount owned by root is the typical cause.
- **backends.go** — the `<layer>.backend` keys: one string type per layer (`MQBackend`, `CacheBackend`, `DedupeBackend`, `CoordBackend`), each with its list of the backends this build has, and a `validate` per layer block (`checkBackend`, run by `validateBackends` in `Validate`, between `validateRoles` and `validateTopology`), which refuses a value not on the list and names the ones that are. One rule spans two layers: while `mq.backend` is `embedded`, `dedupe.lease` plus its own ceiling to the next whole second (`ceilSecond`) plus one more second must fit the embedded MQ's 2m duplicate window (`embeddedDuplicateWindow`), a cap of 59s (`maxEmbeddedLease`), because a client obeying the in-flight `503`'s `Retry-After` can republish as late as the lease plus that ceiling plus a second after the claim, since DynamoDB rounds a claim's expiry up to the second. A backend's own settings go in a `<layer>.<backend>` sub-block that is its `validate`'s case to check. `Distributed` reports whether the MQ is shared with other processes, `NeedsDataDir` whether a selected backend keeps state under `data_dir`, and `Warnings` returns what a valid configuration is still likely to get wrong — the combinations that are harmless or correct for one replica only (a shared MQ over a local cache or Pebble dedupe; under `nats`, `mq.max_bytes_gb` not applied; an `mq.nats` or `coord.nats` block its layer's backend ignores), a `cache.redis` block that is not read, certificate verification turned off — which `app.New` logs at `WARN`. `mq.backend` has two values, `embedded` and `nats` (`MQNATS`), and `nats` reads the `mq.nats` sub-block, which `MQ.validate` checks only when it is selected. `coord.backend` takes `nats` (`CoordNATS`) too, whose `coord.nats` block holds only the bucket name: the leases ride `mq.nats`'s connection.
- **cache_redis.go** — `CacheRedisConfig`, the `cache.redis` sub-block, and its checks: an address (exactly one in `standalone` mode, which dials only the first), each `host:port` with a port from 1 to 65535 (a URL or `user:password@` form refused without repeating it, since it may hold a password), a known mode (`sentinel` is refused until [#656](https://github.com/Wave-RF/WaveHouse/issues/656)), `db` 0 in cluster mode, positive timeouts and sizes, a `timeout` and `dial_timeout` of at most 1 s each (boot and `Close` each wait out a dial: a connect and a handshake bounded by `dial_timeout`, and a cluster's topology read bounded by the larger of the two), a `version_ttl` of at least 2 s, and a `compress_min_bytes` that is not negative (`0` never compresses); its defaults are in `defaults()` with the rest. `CacheRedisTLS.Config` builds the `tls.Config`, reading the files; `Validate` calls it so an unreadable file refuses boot, and `internal/app` calls it again to build the connection. A TLS key set while `tls.enabled` is off is an error rather than a plaintext connection.
- **mq_nats.go** — the two nats blocks and their rules, apart from backends.go for the e2e coverage gate as `cache_redis.go` is: `MQNATSConfig` (URLs, file-path-only credentials, TLS, and the topology to expect; its defaults, `defaultMQNATS`, are in `defaults()`) and its checks, `CoordNATSConfig` (the bucket name), the nats lines of `Warnings` (`natsWarnings`) and the two cross-layer rules `validateTopology` delegates (`validateNATSTopology`: `coord.backend=nats` needs `mq.backend=nats`, a process running `ingest` on a shared queue needs `coord.backend=nats`, and a process running only `sweeper` there is refused).
- **config.go**, roles — `roles` (`[]Role`: `api`, `ingest`, `sweeper`; `AllRoles` by default; `Has(Role)`) picks which components `internal/app` wires, and `instance_id` names the process (`<hostname>-<8 hex>` when empty, resolved in `Load`; logged at boot, and the holder a NATS lease's value names). `validateRoles` refuses an empty list, an empty entry, an unknown or a repeated role; `validateTopology` refuses a role set the backends cannot serve: any split over the embedded MQ, `coord.backend=nats` without `mq.backend=nats` (the leases ride its connection), `mq.backend=nats` with `coord.backend=local` in a process running `ingest` (the ingest processes share the queue through leases), a process running only `sweeper` under `mq.backend=nats` (it would run nothing), and a process with exactly one of `api` and `ingest` over a local cache. `NeedsDataDir` counts Pebble only for a process running `api`, and the cache and dedupe warnings are skipped without `api`, since only that role opens a cache it reads or a dedupe store.
- **strict.go** — `rejectUnknownKeys`, the YAML half: re-reads the file as a generic tree and walks it against the struct's `yaml` tags, listing every key the struct doesn't declare. cleanenv itself is lenient by design, which is exactly wrong for boot config once keys have moved to the settings directory.
- **persistence.go** — `WarnIfFreshDataDir` logs the startup `WARN` when `data_dir` is missing or empty (on a redeploy, the sign that the volume didn't persist); `LogStorageInitError` attaches the UID-65532 `permissionHint` to a NATS or Pebble open failure that looks like a permission denial — the same hint string `CheckDataDir` uses.

### `coord/` — Leases

- **coord.go** — `Coordinator` hands out named leases (and a backend that can tell whether a lease is held without campaigning implements `Observer.Held`, which the ingest processes' membership reads): `TryAcquire(ctx, name)` returns a `Term` if nobody holds a live one, `ErrHeld` if somebody does (this process included), and the term is held until `Resign`, the coordinator's `Close`, or loss; `ctx` bounds the call, not the term. A `Term` carries a fencing `Token` — strictly greater than every earlier term's for the same name on the same backend — and a `Done` channel that closes when it ends, with `Err` saying why (nil after `Resign`/`Close`, wrapping `ErrLost` after a loss). A term can overlap its successor if its holder stalls past the lease duration, so anything that needs strict exclusivity must check `Token` against what it writes; the sweeper does not: an overlap cannot lose ClickHouse data, since every sweep stops at the consumer's ack floor; it can only trim SSE replay history, and only when the two holders' settings views differ (one still reading a shorter `stream.gap_window_minutes`, or missing a tenant, after a reload the other has applied) — which fencing would not prevent either. The package imports only the standard library, so a distributed implementation can live beside the connection it rides on (`internal/mq` for a NATS KV bucket) without a cycle.
- **local.go** — `Local`, the in-process implementation: a mutex-guarded table where the first `TryAcquire` of a name wins and a term never expires; `Held` reads that table. `Peer` returns a second coordinator over the same table, as a second process would hold one over a shared backend (for tests).
- **elect.go** — `RunElected(ctx, c, name, retry, fn)`: campaigns for the lease every `retry` (`RetryPeriod`, 2s), runs `fn` under a context canceled when the term ends, resigns when `fn` returns, and campaigns again, until `ctx` is done or the coordinator is closed (`ErrClosed`, returned). An error `fn` returns while its term is live is returned (fatal to `app.Run`, like any component's); `ErrHeld`, a lost term, and a failed campaign (logged, then retried) are not.
- **coordtest/** — `Conformance(t, factory, opts...)`, the suite every implementation runs against its own backend: one holder at a time, a lease seen as held without campaigning (for an `Observer`), monotonic tokens across holders, `Resign` lets the other in, `Close` resigns every term and refuses more, the context bounds the call and not the term, and — for a backend that can lose a term (`WithLoss`) — loss closes `Done` with `ErrLost`.

### `dedupe/` — Deduplication (Optional)

- **dedupe.go** — the `Deduplicator` contract, two-phase: `Reserve(ctx, keys, lease)` answers one `Claim` per `Key{Table, ID}`, in order — `Claimed` (first sighting: the caller now holds a pending claim), `Duplicate` (committed earlier, or repeated earlier in the same call) or `InFlight` (another request holds a live claim) — and is atomic per key across every process sharing the backend; `Commit(ctx, claims, retention)` makes the published ids duplicates (retention `0` = forever); `Release(ctx, claims)` gives back ids whose records were definitely not published (a refused or never-sent publish; one whose outcome is unknown is left to lapse instead). A claim neither committed nor released lapses after its lease, so a request that dies mid-publish never strands an id. There is deliberately no read-only check: a separate read is how [#390](https://github.com/Wave-RF/WaveHouse/issues/390) happened.
- **key.go** — the key every backend stores, as text: `<tenant>/<table>/<id>` (for example `acme/clicks/evt-123`, [#222](https://github.com/Wave-RF/WaveHouse/issues/222)). The table and id are escaped and joined (`keyenc.AppendJoin`) by `internal/keyenc` — the escaping NATS subject tokens use — which never writes `/`, and a tenant id cannot hold one, so a table name may hold any byte, NUL included, and neither tenants nor tables share ids; a key is ASCII, so it reads as-is in a console and is a valid DynamoDB String. An id whose escaped form is over 1,024 bytes is stored as `#` plus its SHA-256 in hex (`#` is never written by the escaping), counted by `wavehouse_dedupe_hashed_id_total`.
- **embedded.go** — `Embedded`, the [Pebble](https://github.com/cockroachdb/pebble) (embedded key-value store) implementation: every tenant's seen ids in one instance at `data_dir/pebble` ([#583](https://github.com/Wave-RF/WaveHouse/issues/583) story 3). Pending claims live in memory beside it, in 64 locked shards: one process owns the instance, so a crash forgetting them is every lease lapsing at once, and the shard lock makes check-and-claim atomic. `Commit` writes every claim it is given in one batch and one fsync, each value carrying its expiry (`0` = never), which `Reserve` honors on read. A background sweep (`sweep.go`), started when the instance opens and stopped before it closes, deletes expired keys and the version-0 keys from before the table joined the key — told apart by their value, which is never a current commit's, since a bare id from before tenants led the key could spell a current one ([#220](https://github.com/Wave-RF/WaveHouse/issues/220)): a minute after opening, then hourly, 1,024 keys per chunk, read without a lock, so the deleted keys a chunk steps over (Pebble keeps them until it compacts) never hold up a `Commit`, then re-read under a lock `Commit` also takes and deleted only if still expired or version-0, so a key re-committed after the sweep read it is never deleted; `wavehouse_dedupe_swept_keys_total{reason}` counts what it deletes. `NewEmbedded(dataDir)` opens nothing; `Tenant(id)` is the `Factory` a `Stores` takes, building the tenant's `Managed` over its share of the instance, which opens with the first tenant store switched on and closes with the last one switched off. `Stats` reports the instance's figures for the system gauges, nil while it is closed. Pebble is per process: two pods on it do not share seen ids.
- **dynamodb.go** — `Dynamo`, the DynamoDB implementation, selected by `dedupe.backend: dynamodb`: every tenant's ids in one shared table, `pk` (a string) the key above, no sort key. `Reserve` is a conditional `PutItem` per key, run in parallel up to `ReserveConcurrency` (64), with at least as many idle connections kept per host so a wide `Reserve` reuses them rather than dial. It succeeds when no live item holds the key, where an item whose `ex` (epoch seconds, rounded up) has passed counts as absent whether or not TTL has deleted it yet. On a failed condition, the returned old item says `Duplicate` or `InFlight` without a read, or `Claimed` when it is the put's own pending item (same token): an SDK retry of an attempt DynamoDB applied but whose answer was lost. If any put errors, or the caller cancels (a client disconnecting mid-request), the puts not yet sent are skipped and every put that may have landed is released by its token. A put already sent runs to its answer on a context the caller's cancellation does not reach, so it answers before that undo; only its own call deadline can cut it off, and DynamoDB may then apply it after its release, holding its key `InFlight` until the lease ends, as a crashed request's claim does. `Commit` is `BatchWriteItem`, 25 at a time, retrying with jittered backoff, for up to eight rounds, both the items DynamoDB leaves unprocessed and a batch that failed transiently (a throttle means it processed none of it); the records are already published, and a table that throttles every round delays the ingest response by at most about 3 s at the defaults (eight 250 ms calls and the waits between them) before the commit is given up. `Release` is a `DeleteItem` conditional on the token and the pending state. Each call has a `Timeout` (250 ms) covering the SDK's retries (`MaxAttempts`, 3), which back off with full jitter under a ceiling capped at `Timeout/(2·(MaxAttempts−1))`, so a call's retries wait at most half its timeout and a throttled call fails on its last attempt's answer rather than on the deadline. Throttling, server faults, timeouts and connection failures wrap `ErrUnavailable`; a missing table or denied access does not, since those are configuration bugs. Five unavailable claims in a row within a second short-circuit `Reserve` for a second, for every tenant (one breaker per `Dynamo`). `NewDynamo` builds the client from the AWS SDK's default chain (Pod Identity or IRSA), with an `Endpoint` override for dynamodb-local, and refuses a config that resolves no region. `Check` verifies the key schema and warns when TTL is off. `CreateTable` is refused unless `Endpoint` is set. `Tenant(id)` is the `Factory`. The table definition and IAM policy are on the [Deployment](/deployment) page.
- **managed.go** — `Managed` wraps one store — opened through the function `NewManaged` takes, so the switch semantics are the same for every backend — behind the hot-reloadable `dedupe.enabled` switch: `Apply(enabled)` opens or closes it, idempotently, and in-flight calls are serialized against the swap, so flipping the key is a reload, not a restart. Every call returns `ErrDisabled` while switched off (the ingest handler publishes un-deduped and counts it — a reload-window race, not a mode) and `ErrUnavailable` while switched on but not open (ingest fails closed). `Reserve` also reads a lease `<= 0` as `DefaultLease` and collapses a key repeated in one call before the backend sees it, once for every backend — so a backend may assume distinct keys, a positive lease, and only `Claimed` claims in `Commit` and `Release`.
- **dedupetest/** — the conformance suite every backend runs: `Run(t, newHarness)` drives the contract above through a backend's `Factory` (claim, commit, release, lease lapse, retention, one claim among concurrent reserves from two clients, keyspaces, input order, late commit, stale release, failure mid-call); `Harness` optionally injects a clock and a mid-call failure. `Mark` is the old check-and-mark in one call, for tests that only need an id seen.
- **stores.go** — `Stores` is one `Managed` per tenant ([#583](https://github.com/Wave-RF/WaveHouse/issues/583) story 7), built on first use through a `Factory` (`func(tenant.ID) *Managed`) — whether tenants share a backend is the factory's business (`Embedded.Tenant` puts them all in one Pebble instance), with nothing that holds the `Stores` changing. `For(id)` returns a tenant's store, built closed so a tenant adopted a moment ago answers `ErrDisabled` rather than having no store; `Retain(keep)` closes and forgets the stores of tenants no longer served, touching nothing on disk; `Close()` closes every store. `Factory.Gated(ready)` wraps a factory so a store opens only once `ready` returns nil, and fails closed until then (the DynamoDB wiring's table check). `internal/app` drives it from the registry's `AfterAdopt` hook.

### `discovery/` — Schema Discovery & Validation

- **discovery.go** — `SchemaRegistry`, one per tenant since [#583](https://github.com/Wave-RF/WaveHouse/issues/583) story 6, over a `Source` read once per refresh: the tenant's pool's connection and the database that pool was opened for, one snapshot (`App.discoverySource` over `chconn.Pools.For` in production, so a reload that repoints the tenant to another user or `tls` block, which reads the same tables, applies to the next refresh, one that moves it to another address or database has `internal/app`'s `discoveries` drop its registry and build a fresh one, whose first discovery runs at once and whose lookups answer `ErrNotLoaded` until it succeeds ([#638](https://github.com/Wave-RF/WaveHouse/issues/638)), and a refused move keeps discovering the database the tenant's queries and inserts still use; no pool is `ErrNoConnection`), queries `system.columns` to discover ClickHouse table schemas, keeping each column's `default_kind` so `IsInsertable` / `InsertableColumns` / `InsertableColumnNames` (memoized per table at refresh) can decide the insertable subset the ingest envelope and the SSE announcement are both built from. Each refresh also records the server version (`SELECT version()`), joins `system.tables` for each table's `create_table_query` (kept in-process as `TableSchema.DDL` and marked `json:"-"` — an external-engine table renders its wiring in that statement — endpoint, bucket/host, database, username, access key id — so it must never reach `/v1/ops/schema`; ClickHouse masks the password as `[HIDDEN]` from ~23.9, so what is withheld here is the topology), reads each column's `default_expression` and 1-based `position` alongside its type, discovers the server's default time zone (`SELECT timezone()`) and bakes every `DateTime`/`DateTime64` column's canonicalization spec (precision + resolved zone) into the cached schema, so the per-record ingest path parses no type strings and loads no zones ([#372](https://github.com/Wave-RF/WaveHouse/issues/372)). `Lookup` tells the two misses apart — `ErrNotLoaded` before the first successful refresh, `ErrUnknownTable` after — where `Get` answers nil for both (the stream hub's fail-closed reading). Supports periodic auto-refresh (`StartAutoRefresh`, the first tick at a random point within the interval so tenants adopted together do not refresh together, the cadence re-read after every tick), on-demand refresh, and `RetryRefresh` (boot-time exponential backoff loop, each sleep drawn uniformly below the backoff so instances retrying one ClickHouse do not retry in lockstep, used by `internal/app` so a transiently unreachable ClickHouse doesn't crash-loop the binary); a loop's failed attempt counts in `wavehouse_schema_refresh_failures_total{tenant}`. Thread-safe via `sync.RWMutex`.
- **timestamp.go** — `CanonicalizeTimestamps(schema, data)` rewrites every parseable value in a top-level `DateTime`/`DateTime64` column to the canonical RFC 3339 UTC wire form before the event is published ([#372](https://github.com/Wave-RF/WaveHouse/issues/372)): zone-less values are interpreted in the column's declared zone, else the discovered server default — ClickHouse's own rule, so the spelling changes but never the instant. Fail-open: an unparseable value or unresolvable zone passes through verbatim for ClickHouse's own parser to judge; ingest never rejects a record over its timestamp spelling. `Column.TimeParser()` exposes the same grammar as a value→instant parser (nil for a column with no resolved timestamp spec — a non-timestamp column, or one whose declared zone couldn't be loaded), which the stream row-filter uses so filter constants and canonicalized payloads can't disagree on the instant ([#381](https://github.com/Wave-RF/WaveHouse/issues/381)).
- **validation.go** — `Validate(schema, data)` checks incoming JSON against the discovered schema: unknown fields, type compatibility, missing required columns, null handling. Also exports the type classifiers `IsNumericType` / `IsStringType` and the storage-model classifier `NumericStorageOf` (all unwrapping `Nullable`/`LowCardinality`; the latter yields a numeric column's float width, `Decimal` scale, or integer exactness), which — together with `Column.TimeParser` from timestamp.go — seed the stream row-filter's `policy.ColumnSpec` comparison.
- **discovery_test.go** — Unit tests for validation logic.

### `ingest/` — Ingest Pipeline, DLQ & Sweeping

- **worker.go** — `StartIngestWorker` launches an ingest pipeline: a durable `buffer-consumer` consumer of the ingest queue (created through `mq.ConsumerManager`) reads events, batches them per tenant table — the tenant read off each message's `mq.Topic` — and performs bulk INSERTs to ClickHouse. The pipeline is **insert-only**. The wire format `EventMessage` carries `{table_name, scope, received_timestamp, format, columns, row}` — the row positionally as one `JSONCompactEachRow` line, with `columns` naming its positions (the table's insertable columns — a computed one cannot be named in an `INSERT`); the worker batches per (tenant, table, column list) and writes `INSERT INTO … (cols) FORMAT JSONCompactEachRow`. It accepts any table name (events are addressed by `mq.Topic{Tenant, Table, Scope}` with raw names; `internal/mq` encodes them into subject tokens), then bulk-INSERTs. The embedded NATS server runs with `DontListen: true` (`internal/mq/embedded.go`), so under `mq.backend: embedded` the only publishers that can reach the ingest queue are in-process Go code — today, only the HTTP `/v1/ingest?table={table}` handler. Under `mq.backend: nats`, anyone the operator lets publish to `<prefix>.ingest.>` reaches it too, past auth, policy and schema validation, so that right belongs to the `wavehouse` user alone. Non-insert mutations (`DELETE`/`UPDATE`/`TRUNCATE`/…) must go through `POST /v1/ops/query` under the admin role (`policy.admin_role`) — see the Query Path section below; the `/v1/ops/*` `RequireAdmin` middleware enforces the check at the API layer, so a no/invalid-token request (resolved to `default_role`, not admin in a production config) never reaches the proxy — or through an operator-authored [pipe that writes](/pipes#pipes-that-write), gated only by its `allowed_roles`. A batch whose tenant has no ClickHouse connection (no longer served, or no pool could be opened for it, such as by the connection ceiling) is never tried: no row of it could pass, so `parkBatch` takes it to the DLQ switch whole, logging once per batch rather than twice per row. Otherwise a bulk-insert failure is first classed by `chconn.Classify`: a ClickHouse that cannot take the insert (unavailable, denied, or no verdict at all) sends the batch back to the MQ for a delayed redelivery (`retryLater` → `mq.Message.NakWithDelay`), under a backoff shared by every table on the same pool (a failure of one table — read-only, too many parts — backs off that table alone), and never to the DLQ — the same when it stops answering mid-isolation. Only when ClickHouse rejects the batch, or refuses a multi-row batch for its size (`chconn.Splittable`: too many partitions for one INSERT, the memory limit), is it re-inserted row by row: rows that succeed are acked, and only the rows ClickHouse rejects again are routed to the DLQ (`sendToDLQ` → `mq.DeadLetterer.DeadLetter`), which parks the as-published `EventMessage` envelope under the topic it arrived on (`dlq.{tenant}.{table}` subjects inside `internal/mq`) with the failure context in `X-DLQ-*` headers when the tenant's `dlq.enabled` is on for the table — see [Ingest Pipeline](/ingest-pipeline) for the worker internals.
- **backoff.go** — The retry backoff behind `retryLater`: a small circuit breaker per ClickHouse pool (the target's URL, user and database), and one per pool and table for a failure of one table (`chconn.TableScoped`). A failure opens it for 1 s, doubling to a 30 s cap, each window jittered down to half; while it is open, flushes and arriving rows are handed back without a request, and once it elapses one flush probes. Any answer that is not an outage closes it.
- **types.go** — `EventMessage` struct (TableName, Scope — reserved, always empty today, ReceivedTimestamp, Format, Columns, Row; `Format` is `FormatJSONCompactEachRow` and `Row` is one positional line whose slots `Columns` names) and `BufferConsumerName` constant, shared across API handlers and the ingest pipeline.
- **compact.go** — `EncodeCompactRow`, the positional row encoder every published row goes through, rendering one record over the table's **insertable** columns in declaration order. Serialization only: it validates nothing and judges no value.
- **claims.go**, **assign.go** — `ClaimShards` wraps a `Sharded` queue so an ingest process consumes only its share of the units, one process per unit at a time. Each process holds one membership lease, `ingest.m<j>` for the lowest free `j` under the number of configured units (at most 64, read 16 at a time each tick), and every tick (2s) reads which slots are live (`coord.Observer.Held`); `assignUnits` gives every unit to a live slot by rendezvous hashing capped at ⌈units/slots⌉, the same result in every process for the same view; the extras are assigned apart from the configured units, so processes whose lists of extras differ (each refreshes it every five minutes) still agree on every configured unit. A unit no longer assigned halts (`mq.Halter`: stops fetching, keeps its pin, returns once what it fetched reached the worker), waits (bounded by the worker's 60s ack wait, the shortest `ack_wait` a durable may have) for the rows it delivered to settle (`Message.OnSettled`), then releases its pin (`mq.Releaser`). `Halt` on the claiming consumer ends the ticks and halts every unit at once; the worker calls it before its final flush, and the claims' stop releases the units and resigns the membership lease after it. A unit taken over from a slot that is no longer live is reset (`ResetOrphaned`) before it is bound — waiting, unbound, while the broker answers `ErrUnitHeld` (the dead owner's pin has not lapsed, or the unit was active within its pinned TTL), up to 30s — so the dead owner's unacked rows come back at once; on a process's first tick only if it is the one live member (a restart after a clean stop; after a crash the dead run's lease still counts as live for a lease duration). Each unit's rows delivered and unsettled are capped at its share of the worker's 10,000 (`ClaimConfig.MaxHeld`): an even share over the units assigned (`unitShare`, recomputed each tick and read by the broker before every fetch through `ConsumerConfig.MaxHeld`), never under 1,000, two batches; at its share a unit fetches only what keeps its pin, and one stuck unit never takes another's. A row stops counting at its first ack or nak attempt (`Message.OnSettled` fires whether or not the broker confirms), or after `ack_wait` if the worker never settles it, since the broker redelivers it then as a new row. A configured unit whose delivery ends, or whose durable or stream is gone or whose durable no longer fits (`mq.ErrConsumerNotFound`, `mq.ErrConsumerMismatch`) when it is bound, fails the worker; any other bind failure is retried next tick, logged as an error once it has lasted a minute, and an extra's end is logged. The lowest live slot counts the units with rows and no owner (`Sharded.Unowned`) for `wavehouse_ingest_shards_unowned`.
- **sweeper.go** — `Sweeper` implements the Active Sweeper pattern. It runs every minute and asks the MQ (`mq.Purger.PurgeAcked`) to drop the ingest events that are **both** ACKed by the buffer consumer (written to ClickHouse) **and** older than the gap window (re-read every sweep: each tenant's own `stream.gap_window_minutes`, a rejected tenant's as its folder last had it (unbounded for one rejected since boot) — `internal/app`'s `gapWindows` — and none for a removed tenant). Finding the purge point is `internal/mq`'s (`purge.go`).

### `mq/` — Message Queue

The **only** package that imports NATS/JetStream — a `depguard` rule in `.golangci.yml` fails `make lint` on any `github.com/nats-io` import in every package golangci-lint builds; the `integration`-tagged files under `tests/` sit outside its default build context, so the boundary there rests on convention (AGENTS.md Key Design Decision #20). Every other package talks to the broker through the types below, so a subject, stream, or broker change lands here once.

- **mq.go** — The owned surface, stated as intent rather than broker mechanics. `Topic{Tenant, Table, Scope}` is the only address the rest of the process handles (a validated tenant id and raw names; comparable, so the SSE hub keys its index by the value). `Message` carries `Data`, its topic (`Topic()` decodes the delivered key on demand — the tenant included, which is how the hub bridge and the worker learn whose event it is; `TopicKey()` is the delivered form, for log lines), and the ack family (`DoubleAck(ctx)`, `Ack()`, `Nak()`, `NakWithDelay(d)`, which falls back to `Nak` for a message built without `WithNakDelay`); `Headers` is the message header map (`Add`/`Set`/`Get`, exact-key) that `PublishOpt`s such as `WithHeader` shape; `WithIdempotencyKey` marks a publish so that a second one carrying the same key inside the queue's duplicate window is dropped and reported as success. Interfaces, each speaking per tenant and never per stream: `Publisher` (`ErrQueueFull` when the tenant's ingest queue is at its byte budget or not open yet — the API's 503 + `Retry-After: 30`; `ErrUnavailable` when the broker cannot be reached or does not answer in time — the 503 + `Retry-After: 5`, which the external NATS backend returns; the embedded broker's publish failures are the `500`), `Subscriber` (every ingest event of every tenant, under a named durable consumer, its fetch-ahead split across the tenants — the hub bridge), `ConsumerManager` → `Consumer` (a durable explicit-ack consumer from a `ConsumerConfig`, whose `MaxAckPending` holds per tenant, and which may name `Units` of a `Sharded` broker — one whose ingest queue is split into units one consumer at a time owns, with `IngestUnits`, `ResetOrphaned` (`ErrUnitHeld` while a consumer holds the unit or was active on it within its pinned TTL) and `Unowned`; such a consumer is a `Releaser`, may be a `Halter` (stops fetching ahead of its stop, keeping its hold on its units, so the worker writes what it holds first), is capped per unit by `ConsumerConfig.MaxHeld`, and `Message.OnSettled` (settle.go, apart from mq.go for the e2e coverage gate) runs a hook once, at the first ack or nak attempt, confirmed or not; `Consume` delivers each tenant's messages on a goroutine of that tenant's, in order, so a blocking handler is backpressure on its own tenant alone, spreads the prefetch across the tenants, and returns a `stop` plus a `failed` channel that reports delivery ending on its own — `ErrDeliveryEnded`, e.g. a deleted consumer or a closed connection — since no message would ever say so) for the ingest worker, `DeadLetterer.DeadLetter` (park a message under its own topic, in its tenant's dead-letter queue; the caller acks), `DeadLetterStats.DeadLetterCounts` (one tenant's; `ErrNoDeadLetterQueue` when it has none), `Purger.PurgeAcked` (drop what is both acked by a consumer and stored before its tenant's cutoff, everything acked for a tenant given none; one error per failed tenant, joined — `ErrConsumerNotFound` for a queue the consumer has not been created on yet, the one failure the sweeper logs as a warning rather than an error) for the sweeper, and `Replayer.ReplaySince` for SSE gap-fill. `Broker` composes them with each tenant's byte budget (`SetMaxBytes`/`MaxBytes`), `Stats`, and `Close`; it is what `internal/app` holds.
- **subject.go** — The embedded broker's naming, private to the package: the stream names (`INGEST_<tenant>` and `DLQ_<tenant>` — prefixes that differ in their first letter, so no tenant id makes one kind's name the other's — and the one pair an earlier build kept for every tenant together, `WAVEHOUSE`/`WAVEHOUSE_DLQ`, which boot deletes), the `ingest.`/`dlq.` prefixes and `>` wildcards, the subject tokens (`internal/keyenc`: ASCII letters, digits, `_` and `-` pass, everything else is percent-encoded, so a name can never split or wildcard a subject), and `Topic` ↔ subject conversion. A subject is `<prefix><tenant>.<table>[.<scope>]`: the tenant verbatim — its grammar (`tenant.Parse`) makes it one token, and it is checked on the way to the wire, so a topic without one has no subject — then the table and scope as encoded tokens; tenant first so one wildcard selects a tenant's traffic (`ingest.acme.>`). A topic has the same tail on both streams, so parking on the DLQ is a prefix swap on the delivered subject — nothing is decoded or re-encoded — and the tail's first token picks the tenant's stream.
- **deadletter.go** — `deadLetterTables`, the per-table count `DeadLetterCounts` reports: a dead-letter stream's per-subject counts, each subject parsed back to its topic and counted under its table — every scope of a table under the table itself, so a dotted table name never shares a count with a table + scope pair — and a table filter keeps that table with all of its scopes.
- **purge.go** — The Active Sweeper's arithmetic over JetStream sequences: purge target = `MIN(consumer ack floor + 1, first sequence stored at or after the cutoff)`, the latter found by binary search over message timestamps (~15 lookups). Every uncertainty resolves toward purging less: a sequence that holds no message is kept as a candidate bound rather than discarding the half below it, and a lookup that fails outright aborts that tenant's purge. It runs on each tenant's stream at that tenant's cutoff, and a failure on one tenant's stream is reported without stopping the sweep of the others. Healthy state keeps exactly the gap window; ClickHouse down freezes purging; a catastrophic outage fills the stream to `MaxBytes` and `DiscardNew` pushes back.
- **external.go** — `ExternalNATS`, the `Broker` over an operator-owned NATS cluster (`mq.backend: nats`): N work-queue ingest partitions shared by every tenant, each split into V shards by a subject token (`natsRoute`: jump hashes of the tenant and table, one for the partition and an independent one for the shard), with one pinned-client durable per shard, each partition republishing every row it stores to the history stream under `<prefix>.hist.<topic>` for SSE replay and the hub, and one dead-letter stream. Ingest publishes do not name the stream they expect, since the republished copy would carry that expectation to the history, which would refuse it; the ack's stream is checked instead. It never creates, changes, purges or deletes a stream or a durable; it creates only auto-expiring consumers on the history stream, one per `Subscribe` and one per replay. `NewNATS` connects and waits for the topology to pass the verifier; publishes carry a `Nats-Msg-Id` reused across retries; a broker that does not answer is `ErrUnavailable`; `PurgeAcked` removes nothing; as a `Sharded` broker it names its units (`<stream>/<durable>`, the configured N×V then any extras a lower N or V left holding rows, which are drained like the rest); a consumer (external_consumer.go) fetches each unit in the priority group `wavehouse`, 1s pulls sized to what the unit's cap leaves room for, on a goroutine that never runs the handler (another hands the rows on, in order), so the server delivers a unit to one pinned puller at a time and a busy handler never lets the pin lapse; at the cap it renews the pin every 5s with a one-row fetch, up to one `ack_wait` of renewals past the cap (a pull that held a due redelivery back would have the server requeue it behind the others), and otherwise, and once halted, with a pull of `max_bytes` 1, which renews the pin and delivers nothing (the server holds the row back). `Halt` stops fetching and returns once the fetch in flight has ended and its rows reached the handler; a halted unit keeps renewing until `Release`, or until `ack_wait` and one renewal have passed since it handed on what it fetched. `Release` unpins a unit only while the server still holds this consumer's own pin (from the `Nats-Pin-Id` of the last row it received); the check and the unpin are two requests, and the pin cannot lapse between them because the unit renews it until the unpin returns, for up to `ack_wait` (and one renewal) after its halt has handed on what it fetched, which outlasts the handover's own wait. A pull that gets no responders twice in a row, or a renewal that gets one, looks the durable up, and a durable or stream that is gone ends the unit's delivery (`failed`). `ResetOrphaned` resets a unit's durable to its ack floor when no client is pinned, the durable delivered nothing and had no ack within its pinned TTL, and rows await an ack; a caller does it before it pulls the unit, never after, since a reset also redelivers the caller's own unacked rows. `Unowned` applies the same activity test. A consumer of every unit resets orphaned units before pulling and releases each after its halt. It exports the `wavehouse_mq_connected`, `wavehouse_mq_topology_ok` and `wavehouse_mq_history_behind_seconds` gauges.
- **lease.go** — `ExternalNATS.Leases(ctx, bucket, holder)`, the `coord.Coordinator` (and `coord.Observer`: `Held` counts another holder's lease as held until this coordinator has seen its revision unchanged for the lease duration, the clock `TryAcquire` keeps) for `coord.backend: nats`, over the operator's KV bucket (a missing one is `ErrTopology`; WaveHouse never creates it). A lease is the key `lease.<name>`, its value JSON `{holder, duration_ms, session}`; the KV revision a term was taken at is its fencing `Token`. `TryAcquire` creates an absent (or resigned: a delete marker) key; takes over another holder's only after seeing the same revision unchanged for the lease duration on its own monotonic clock (no clocks are compared, and the bucket keeps no per-key TTL, which a renewal could not extend), by a compare-and-set at that revision; and resumes its own write (same `session`) at once. The term renews every `coord.RetryPeriod` (2s) at the revision it last wrote; a write refused for its revision ends it with `ErrLost` unless the key holds its own value at a later revision (a renewal whose answer was lost), and a timer ends it the moment the renew deadline (10s, before the 15s lease duration) passes without a stored renewal, timed from when that renewal was sent, since a candidate's clock can start as soon as it is stored. `Resign` and `Close` cancel a renewal in flight and delete the key at the last revision (or at a later one holding this term's own value, a renewal the cancel cut short), so a successor need not wait. `TryAcquire` holds no lock across a request. `WithLeaseTimings` shortens the timings for tests.
- **nats_topology.go**, **nats_manifests.go**, **subject_nats.go** — what the operator must create (`NATSTopology`: with a dedupe lease, every partition's duplicate window must cover the lease, the lease rounded up to a second, and one more second; with the lease bucket, `CoordBucket`, checked only when a process holds leases there: it must exist, keep a value per key, allow direct gets, and expire nothing), the verifier that checks a live server against it and reports every finding (required or recommended), the nack resources `wavehouse mq manifests` prints from the same spec, sized to fit the servers' file store (`deployments/nats/jetstream.yaml` is its output for N=4, V=8), and the `wavehouse` user's permissions `wavehouse mq permissions` prints (one entry per shard durable: a NATS wildcard is a whole token; both API forms under a JetStream domain), and the external broker's subjects (`<prefix>.ingest.<p>.<s>.<tenant>.<table>`, `<prefix>.hist.<tenant>.<table>`, `<prefix>.dlq.<tenant>.<table>`).
- **nats_permprobe.go** — The verifier's permission probe: a pull and an unpin to each shard durable that the server rejects on their merits, so a refused one (reported only to the connection's error handler) names a shard the user may not pull from or unpin.
- **natstest/** — Test code that stands up NATS as an operator deploys it, from the shipped `deployments/nats` values and manifests: the config for a server, with the file store the Helm chart would give it (in process, or in the integration suite's container) and the operator's hand on it (applying the manifests, the lease bucket included; deleting a durable or the bucket; reading which process holds a lease). It lets `internal/app` and `tests/integration` run against a real server without importing NATS themselves.
- **embedded.go** — `EmbeddedNATS`, the in-process `Broker`: an in-process NATS server with JetStream, giving each tenant a queue of its own — stream `INGEST_<tenant>` with subjects `ingest.<tenant>.>`, capped at the tenant's `mq.max_bytes_gb` (`DiscardNew`) and remembering idempotency keys for `EmbeddedDuplicateWindow` (two minutes, sized to `2 × the dedupe lease + 1s` — the in-flight `503` sends the full lease as `Retry-After`, so an obedient client's retry can land up to ~2×lease after the original `Reserve`, and the `+1s` covers a backend whose claim expiry itself rounds up by that much), and stream `DLQ_<tenant>` (`dlq.<tenant>.>`, `DiscardOld`) at a tenth of it — with the durable consumers on the ingest one; nothing outside the package sees that layout. JetStream's own check of the streams' caps against the disk (75% of the free disk by default) is set out of reach, so a budget is a cap and never a reservation. Boot deletes the pair an earlier build kept for every tenant together — its subjects overlap every tenant's — and takes stock of the tenants' streams on disk with their budgets, so a consumer created later is held on every one, a tenant no longer served included. `SetMaxBytes` opens a tenant's queue the first time — its dead-letter stream first, so no row is queued that could not be parked — and every registered consumer joins it; a queue a consumer cannot join is not recorded open, `SetMaxBytes` returning the error and `MaxBytes` not reporting the budget, so the next reload tries the consumers that had not joined again. A publish or park that finds a stream missing reopens it at the budget last asked for the tenant, and so does a publish to a queue the broker has not recorded open — one a consumer could not join, or an open that timed out, which can leave a stream JetStream creates after all, which no consumer holds — either refused as a full queue with none asked yet. Publishes and parks that find the same queue not open share one attempt (`singleflight`), and after one fails the tenant's publishes and parks are refused at once for five seconds rather than each trying again under the broker's lock, which every tenant's open, resize and reload takes; a reload retries regardless. After that `SetMaxBytes` applies a reloaded budget to the tenant's two live streams as a pair: if the DLQ update fails after the ingest one succeeded, the ingest resize is undone so both stay on the previous budget — best effort, since if that undo also fails the ingest stream stays at the new limit and the DLQ at the previous, and the error says so. A dead-letter stream is never capped below the bytes it holds, which `DiscardOld` would delete to fit ([#532](https://github.com/Wave-RF/WaveHouse/issues/532)): it keeps what it holds, and that is logged. Its JetStream calls are bounded to ten seconds — plus ten more for the consumers joining a queue they do not hold yet, and five for the rollback of a failed resize, each a budget of its own rather than the one that just expired — since a reload holds the settings store's lock while its hooks run; `MaxBytes` reports the budget last applied in full, so a failed resize is retried by the next reload. The consumers `CreateConsumer` and `Subscribe` build hold one durable on each tenant's stream, looked up before anything is written so a boot over many queues writes nothing it need not, each delivering on a goroutine of its own into the one handler. `PurgeAcked` and `DeadLetterCounts` run per tenant stream. `Stats` reports connection and inbound-message counters for `observability.RegisterSystemMetrics`. Trace context rides in the message headers: `Publish` applies `observability.InjectHeaders`, and a message delivered through `Subscribe` (the hub bridge) carries `observability.ExtractHeaders` on its `Ctx`; the worker's `Consumer` path skips the extraction, since it batches across messages and reads no per-message context.
- **mqtest/** — The conformance suite for `Broker` (`mqtest.Run`): the behavior the rest of the process relies on — publish and consume round trips with names that need encoding, per-tenant order, redelivery, dead-lettering and its counts, replay bounds and isolation, the one `failed` report of a consumer whose delivery ends underneath it — checked through the interfaces alone, with no stream or subject name in sight. Each implementation runs it from a test of its own — the embedded one from `mqtest/embedded_test.go`, a test binary apart from `internal/mq`'s so the two share no 15s budget — handing it a fresh broker per case and flags (`mqtest.Caps`) for the few places where backends legitimately differ: whether a full queue refuses its own tenant alone, whether `PurgeAcked` removes anything, whether a tenant never given a budget has a dead-letter queue to report on, and whether `CreateConsumer` configures the durable or only finds one.

### `observability/` — OpenTelemetry Pipeline

- **provider.go** — `InitProvider(ctx, serviceName, ProviderConfig)` wires the OTel pipeline. Each output is independently gated; the W3C TraceContext + Baggage propagator is always installed (cheap, harmless when traces are off). Returns `(shutdown, promHandler http.Handler, err)` — `promHandler` is non-nil only when `PrometheusEnabled` is true and reads from a *private* `prometheus.Registry` to avoid leaking the process/Go collectors that `prometheus.DefaultRegisterer` auto-registers. OTLP-metrics push (`MetricsEnabled`) and Prometheus exposition (`PrometheusEnabled`) are independent: either, both, or neither may be set, and any combination produces a single MeterProvider feeding the active readers. The OTLP exporters (traces / metrics-OTLP / logs) take their endpoint from the `OTEL_EXPORTER_OTLP_*` env vars, not from `ProviderConfig`, and are only constructed when their signal is enabled, so Prometheus-only operation dials nothing even with those vars set. Provider init in `internal/app` runs whenever `otel.enabled` OR `prometheus.enabled` is true, so Prometheus-only operation (Alloy/scrape, no collector) is a first-class mode.
- **logger.go** — `NewLogger(component, level, isJSON, otlpSampleRate)` produces a slog logger that fans out to stdout (always 100%) and the OTLP log exporter (DEBUG/INFO sampled at `otlpSampleRate`, WARN/ERROR always 100% as a non-configurable safety floor). `TraceHandler` injects `trace_id`/`span_id` from the active span when one exists. `otlpSamplerFn` is exposed (lowercase) for unit testing the per-level rate logic without driving through the slogmulti middleware.
- **metrics.go** — `RegisterSystemMetrics(mqStats, pebbleStats)` registers observable gauges for the MQ's NATS connections and in-msgs (the embedded server's, or under `nats` this process's client connection), and Pebble dedupe storage stats. Both are functions read on every scrape — `mqStats` a `func() (MQStats, error)` (`mq.Broker.Stats` in production; nil skips the MQ gauges), `pebbleStats` a `func() map[string]int64` (`dedupe.Embedded.Stats`, the one instance's figures; nil, or a nil map while it is closed, skips the Pebble gauges) — this package never holds the NATS server or a store. Wired in `internal/app` after the providers are up.
- **tracer.go** — W3C TraceContext propagation over message headers (`InjectHeaders` / `ExtractHeaders` on a plain `map[string][]string`, the shape NATS and HTTP headers share) — `internal/mq` injects on every publish and extracts onto the delivered `Message.Ctx` on the `Subscribe` path; no consumer reads it yet (the SSE hub bridge forwards the bytes and starts no span), and the ingest worker's `Consumer` path skips extraction entirely (see `embedded.go` above), so nothing downstream of the queue is linked to the originating request span.

The package's design invariants — stdout always 100%, WARN+ERROR always export at 100%, gRPC exporters dial lazily so unreachable collectors never block startup, private Prometheus registry — are documented in AGENTS.md "Key Design Decisions" #15 and must be preserved by anything touching this package.

### `policy/` — Access Control

- **policy.go** — Hasura-style policy types, now **role-first**: `TablePolicy` is `map[string]RolePermissions`, and a role's grant carries a separate `SelectPermissions` and `InsertPermissions` — so a field only one side ever honored (`filter`, aggregations and the `max_*` limits on select; `check` on insert) does not exist on the other, and a document that puts one there fails the strict decode as an unknown key. `Evaluate()` resolves permissions against JWT claims (including `{{ jwt.claim.path }}` template resolution) for ONE operation, leaving the side it did not resolve **nil**. That is what the pointers buy: an *empty* side means "no restrictions" — what the admin return constructs on both sides — while a *nil* side means "you asked the wrong operation", and as value types the two were the same zero value. Every accessor fails closed on a nil side. The handful of bare field reads outside this package each sit past an accessor that denies an unresolved side first, so a nil `Select` never reaches one; if that ordering ever changed they would panic rather than silently widen. A nil guard that skips such a read must never be added, since an absent `WhereClause` is an unfiltered query. The per-column decision `IsColumnAllowed(col, insert)` takes the side it is being asked about, alongside its batch/projection forms `AllowedProjection()` and `RestrictsColumns()`, `IsAggregationAllowed()`, `CheckClauses()` — the write-side accessor for the one consumer that iterates a side's map instead of asking about a column, whose `ok=false` a caller must treat as *refuse the write*, never as *no checks to run* — `resolvePredicates()` — the one resolution both read surfaces render from, so the SQL `WHERE` and the in-memory row check cannot drift — and `Validate()`, split into `validateSelectPerms`/`validateInsertPerms` and run from `settings.Validate` on every adoption, which is where the rules in [Access Control](/access-control) are actually enforced.
- **rowfilter.go** — the in-memory row-visibility twin of the SQL `WHERE`: `HasRowFilter`, `RowVisible` (evaluates the resolved predicates against a decoded event, per subscriber), and `ColumnSpec` — the per-column comparison contract (`ColumnKind` `Numeric`/`Text`/`Time`/`Opaque`, plus each kind's parameters: the caller-supplied instant parser for `Time`, the `NumericSpec` storage model for `Numeric`) whose zero value is the fail-closed floor: numeric columns compare in the column's **storage domain** (operands rendered by canonical.go, compared by numeric.go — next two bullets), `String` bytewise, `DateTime`/`DateTime64` chronologically (both operands through the ingest grammar; either side unreadable ⇒ withheld), and everything else (including any column with no usable schema) admits byte-equality only, failing `!=`/`>`/`<` closed. Both `HasRowFilter` and `RowVisible` fail closed on a denied or unresolved grant: `HasRowFilter` is the gate in front of `RowVisible`, so it must answer *true* there or the whole-bucket fast path skips the check entirely.
- **canonical.go** — the one rendering layer for comparison operands: every value a `filter` or `check` compares — a JWT claim (`CanonicalScalar`), a policy-authored literal (`CanonicalNumericLiteral`), an ingested payload value (`numericCanonical`) — converges on one exact canonical decimal form (positional, digit-bounded, never a float64 round-trip), so what a read filter binds and what the stream compares can't drift; `scalarString` is the deliberate exception, the raw byte rendering that `Text`/`Opaque` equality compares.
- **numeric.go** — compares canonical forms the way the column that stores them would: `compareCanonicalDecimals` orders by exact digit-string arithmetic, and `NumericSpec` first narrows both operands the way ClickHouse narrows the stored value and the bound constant — `Float32`/`Float64` width rounding, `Decimal` scale truncation, integers exact at any width, with an operand outside the column's width or a `Decimal`'s precision budget refused rather than modeled; the `tests/integration` differential oracle holds in-range verdicts equal to a live ClickHouse's and asserts the never-admit-where-SQL-hides direction for the refused out-of-range operands.
- **source.go** — `Source`, a `func() *Policy` the `/v1/ops` gate (over a flat settings directory) reads per call, so a settings reload applies to the very next request; in production it is the default tenant's `settings.Store.Policy`, and `Static(p)` fixes one for tests. The tenant-aware surfaces take a keyed variant that resolves to the same `Store.Policy`: `api.PolicySource` (`func(*settings.Store) *policy.Policy`) for ingest, structured query and pipes, `stream.PolicySource` (`func(tenant.ID) *policy.Policy`) for the hub, and `auth.PolicySource` (same shape) for the operator key's admin role. A `nil` result is a deliberate lockout.

### `pipes/` — Named Query Pipes

- **pipes.go** — `NamedQuery` type with SQL template and parameter definitions, and `Source` (`Pipe(name)` / `Pipes()`), read per request — `settings.Store` in production (`pipes.json`), `Static(q...)` in tests. `BindParams()` resolves `{{param}}` / `{{param:default}}` placeholders by inlining escaped literal values into the SQL (strings single-quote-escaped; arrays rendered as escaped `(…)` `IN`-lists). A non-scalar value with no SQL form (a JSON object, or an empty array) is rejected rather than emitted raw.

### `query/` — Structured Query Engine

- **ast.go** — `StructuredQuery` AST types: columns, aggregations, filters, group by, order by, limit, time range.
- **builder.go** — `Build()` converts AST to parameterized SQL. It is the single chokepoint that validates every referenced identifier against the schema **and** authorizes every column reference — projection, aggregation args, filters, group_by, order_by, time_range — against the role's column allowlist (the [#223](https://github.com/Wave-RF/WaveHouse/issues/223) hard cap). A full-row read is requested with `select_all`, which expands to the role's allowed columns rather than emitting a raw `SELECT *`; an omitted projection selects nothing, and `*` in `columns` is a literal column name. Every identifier is backtick-quoted via `internal/chsql` (`QuoteIdent`) so any ClickHouse-legal name is accepted — a name containing `?` is refused fail-closed ([#279](https://github.com/Wave-RF/WaveHouse/issues/279)). The role's row-level-security predicate and `max_rows` cap are emitted by `Build()` itself, as part of the WHERE and LIMIT assembly — policy SQL is never spliced into rendered text ([#322](https://github.com/Wave-RF/WaveHouse/issues/322)). Timestamp bucketing for cache optimization.

### `settings/` — Settings Directory

The hot-reloadable half of configuration: a directory of four JSON files (`config.json`, `roles.json`, `policies.json`, `pipes.json`) — or, [nested](/deployment#the-nested-settings-directory), one folder of those four per tenant — that the server validates at boot and re-adopts while running. `Validate` is the single gate (strict decode, per-file shape rules, and the cross-file check that every role a policy grant or pipe allowlist names is declared in `roles.json`), a `Store` holds one tenant's adopted `Document` as one atomic snapshot, and the `Registry` maps tenant ids to stores and owns every reload. `Store` is also the runtime authority for access control and pipes: it implements `policy.Source` (`Store.Policy`, nil when `policies.json` is `{}` — fail closed) and `pipes.Source`, and there is no other copy — the files are the only write path. See [Settings Directory](/settings-directory).

- **validate.go** — `ValidateDir(dir)` reads, decodes, and checks one directory of the four files — a flat root, or one tenant's folder — in one pass (strict JSON — unknown fields and duplicate keys are errors; per-file shape rules; cross-file role references) and returns every `Finding` at once. Shared, by way of `Validate`, by `wavehouse validate`, boot, and every reload.
- **finding.go** — `Finding` / `Severity`: errors make the directory invalid, warnings don't block adoption. The JSON shape is part of the ops API (`POST /v1/ops/settings/reload` returns them).
- **tree.go** — `Validate(root)` reads the directory's shape off its entries and checks either one: a root holding any of the four file names is flat and goes through `ValidateDir` untouched; otherwise a root holding a folder is nested, each folder name going through `tenant.Parse` and each folder through `ValidateDir`, with the folder leading every finding's `File` (`acme/policies.json`). The `Tree` it returns is nil when the finding is about the root itself — it cannot be listed, or a nested root holds a loose file or an entry that cannot be stat'ed.
- **store.go** — `Store` is a passive holder: one tenant's adopted document behind an atomic pointer, swapped by the registry, which stamps it with the id of the tenant it created the store for (`Tenant()`, how a handler names its tenant to a per-tenant resource). Consumers read typed accessors per call (`ClickHouse()`, `Auth()`, `DedupeFor(table)`, `DLQFor(table)`, `Keepalive()`, …) rather than holding values.
- **registry.go** — `Registry` maps a tenant id to its `Store` and owns everything that changes one. `Open` validates and adopts at boot; `Reload` re-validates the whole directory and `ReloadTenant` one tenant's folder, serialized with each other; `AfterAdopt` hooks run after every reload the registry applied, with the tenants it adopted — none when it only rejected or removed one, which a consumer holding a resource per tenant needs to hear of too; `For(id)` and `All()` see only the tenants being served, `Known()` every tenant held, rejected ones included, and `Resolve(id)` tells a rejected tenant from an unknown one. The shape is fixed at `Open`. Flat: an invalid directory refuses boot, and a rejected reload keeps the previous snapshot. Nested: fail closed per tenant — a folder with an error finding stops being served (the store keeps its document for requests already admitted, and gets the next good one) while the rest carry on; a whole-directory reload mirrors the folders, down to none (an emptied directory is not a change of shape); and a finding about the directory itself refuses boot or rejects the reload whole, leaving every tenant as it was; at `Open`, a nested directory that would serve no tenant (every folder rejected, or none named by a tenant id) refuses boot too, while a reload to the same tree drops every tenant and keeps running. The tenant map is replaced whole by a reload, so a lookup is one lock-free load.
- **watch.go** — `Registry.Watch`, which `internal/app` starts for a flat directory only: fsnotify on the *directory* (not the files, so atomic-writer replaces and Kubernetes ConfigMap symlink swaps aren't lost), debounced into one reload; reloads once as soon as the watch exists so an edit between the boot read and the watch is never missed. `SIGHUP` and the reload endpoint funnel through the same serialized `Reload`.
- **seed.go** / **seed/** — The embedded (`go:embed`) starter directory with every key at its default. The binary carries no compiled defaults except that a missing `dedupe.retention` means `"0"`: `wavehouse bootstrap [dir]` writes this seed, and the compose stack and e2e fixture ship copies of it.

### `tenant/` — Tenant Identifier

- **tenant.go** — `ID`, a validated string (never a number: a 19-digit id already rounds as a float64), and `Parse`, the one grammar that makes an id safe both as a folder name and as a message-queue subject token: ASCII letters, digits, `_`, `-`, at most `MaxLen` (64) bytes. `Default` (`"0"`) is the tenant a request without the header resolves to; `Header` is `X-Tenant-ID`. The package imports nothing from the rest of the repository, so any package can name a tenant. HTTP handlers receive the tenant as its resolved `*settings.Store`, which knows its id (`Store.Tenant`) for the topics they publish and subscribe on; the stream hub and the ingest worker read each event's tenant off its `mq.Topic` — the leading subject token — and their settings getters take it as a parameter, which `internal/app` resolves through the registry; the sweeper hands the MQ each tenant's own gap window (`gapWindows`, a rejected tenant's included); each served tenant has a schema registry of its own, built with its id (story 6).

### `chconn/` — ClickHouse Connection Pools

- **chconn.go** — `Pools` holds one `Manager` per distinct connection tuple among the served tenants — `Identity{Addr, Database, Username, Password, TLS}`, a plain comparable value, the map key — reconciled from the settings registry's `AfterAdopt` hook after every reload ([#583](https://github.com/Wave-RF/WaveHouse/issues/583) story 6): tenants naming one tuple share its pool, sized to the largest `max_open_conns` and `max_idle_conns` among them (`Sizes`); a tenant whose tuple changed is repointed; a tuple no tenant names is released after the longest `query_timeout` among the tenants it had; a changed largest ask is a `Resize` with the same grace. The walk keeps the boot config's `clickhouse.max_total_conns` — the ceiling on the open pools' `max_open_conns` together — at every step, tenants no longer served leaving first and what was refused placed once more at the end: a refused resize keeps the pool's size, and a tuple that cannot be opened (the ceiling, a certificate file that cannot be read, or options the driver refuses — the pool opens as the walk places its first tenant, at the largest ask among the tenants naming it when that fits the ceiling and otherwise at that tenant's own, so each of these is undone in place) leaves its tenants on the pool they had — `Params` and all, so their `Target` stays whole — or on none; `NewPools` refuses boot on any refusal, `Reconcile` returns them joined for the wiring to log, and the next reload retries. `Manager` is a `driver.Conn` over one tuple's pool whose backing connection `Resize` swaps; like `clickhouse.Open` it never dials, so boot tolerates an unreachable ClickHouse (schema discovery retries) and a bad address surfaces where reachability is already handled (`/readyz`, query errors). The `tls` block is the tuple's, read once into one `tls.Config` handed to the driver when `tls.enabled` and carried on each tenant's `Target` for the https hop. Resolution is per tenant: `For` (the `driver.Conn`, nil for a tenant on no pool — the wiring returns an untyped nil), `Target` (the tenant's own `http_port`, `http_scheme` and `headers` over its pool's host, credentials, database and TLS config, from the `Params` last applied for it), `SharingTables` (the tenants on the same address and database, whatever their user — the cache fan-out's rule) and `Ping` (every pool at once, nil at the first answer). The HTTP-interface consumers (ingest INSERTs, raw-SQL proxy) take their `http.Client` from an `HTTPClients` cache, one client per TLS config ever handed to it, since the proxy serves tenants on different configs in alternation.
- **errclass.go** — `Classify`, what a failed ClickHouse request says about the request: `Unavailable` (connection refused/reset, timeouts, and the exception codes of a server that cannot take work — `TIMEOUT_EXCEEDED`, `TOO_MANY_SIMULTANEOUS_QUERIES`, `MEMORY_LIMIT_EXCEEDED`, `TOO_MANY_PARTS`, `READONLY`, `TABLE_IS_READ_ONLY`, `KEEPER_EXCEPTION`, …), `Denied` (`AUTHENTICATION_FAILED`, `ACCESS_DENIED`, …: the identity, not the request), `Rejected` (any other exception code — the server read the request and refused it), or `Unknown` (no exception code, and no failure recognizable as the way to ClickHouse). It reads the driver's `*clickhouse.Exception`/`*clickhouse.HTTPError` and the HTTP interface's `HTTPError` (`NewHTTPError`: the code from `X-ClickHouse-Exception-Code`, else the body's `Code: NNN.`), so it is one answer for the ingest worker and the query handlers (`api/ch_errors.go`); the worker retries every class but `Rejected`. `TableScoped` and `Splittable` refine a retried failure: the first names a failure of one table (read-only, too many parts or mutations, a missing grant), the second one a multi-row batch can earn by its size alone (`TOO_MANY_PARTS` for too many partitions in one INSERT, `MEMORY_LIMIT_EXCEEDED`), which the worker splits row by row before retrying.

### `chsql/` — ClickHouse SQL Helpers

- **chsql.go** — Dependency-free ClickHouse SQL helpers shared by `query/` and `policy/`, kept in their own package to break an import cycle. `QuoteIdent` is the single place every identifier — column, table, alias — becomes SQL text: always backtick-quoted and escaped, so any ClickHouse-legal name (dots, spaces, unicode, keywords) is safe. `BindUnsafe` reports whether a name contains a literal `?`, which would desync clickhouse-go's positional binder; such names are rejected fail-closed rather than silently mis-bound.

### `keyenc/` — Key Escaping

- **keyenc.go** — The one escaping composite keys are built from, so a name can never be mistaken for a separator: `Escape` keeps ASCII letters, digits, `_` and `-` — exactly the tenant-id grammar, so a tenant id is its own escaped form — and writes every other byte as `%XX` (uppercase hex); `Unescape` is `url.PathUnescape`, which decodes `%XX` in either case and takes any other byte as itself, so a `%2D` for `-` that an earlier build wrote still reads. `Join`/`AppendJoin` escape each field and put a separator between them, panicking on no fields and on a separator the escaping could write or one outside ASCII, and `Split` reverses them. The package that builds a key takes raw names and escapes them itself, so no caller has to: NATS subject tokens (`internal/mq`), the cache's keys — the version index and the shared backend's Redis keys (`internal/cache`) — and the dedupe keys (`internal/dedupe`, `/`-separated) use it. Keys built from it are stored, so changing what it keeps orphans them — and on the shared backend, whose keys every process builds for itself, it splits them for the length of a rolling upgrade: a bump one build makes does not reach the entries the other build filed, which are served until their TTL.

## Data Flows

### Ingest Path

```text wrap=false
Client POST /v1/ingest?table={table}
  → Tenant resolution: X-Tenant-ID → settings.Registry → the request's *settings.Store
    (absent = tenant 0; 400 malformed / 404 unknown / 503 rejected, before auth)
  → JWT auth middleware (always runs; token optional)
  → refuseUnverifiable (503 + Retry-After: 30 for a token sent while the
    tenant's JWKS has not been fetched yet; tokenless and operator-key
    requests pass through)
  → Look up table schema from SchemaRegistry
  → Policy check: role allowed to insert into this table (before the body is parsed)
  → Resolve the declared Content-Type into the body's format (415 if absent,
    unsupported, or if declarations disagree; before the body is read)
  → Read the whole body into a pooled buffer, bounded by the 16 MiB cap
    (413 before any record is processed, so nothing is published)
  → Validate JSON body against schema (type checks, required columns)
  → Policy column rules + check clauses (disallowed columns rejected;
    claim-derived values enforced or injected)
  → Canonicalize top-level DateTime/DateTime64 column values to RFC 3339 UTC
    (rewrites the payload so every consumer shares one spelling; fail-open —
    an unparseable value passes through verbatim for ClickHouse's parser to judge)
  → Optional dedupe: resolve the id (configurable ID field; a row missing it or
    setting it to null is published un-deduped + logged/counted, or rejected
    under require_id)
  → Encode the record; the steps below run per window of up to 256 records
  → Reserve the window's (tenant, table, id) keys in one call: a duplicate is
    skipped, an id another request holds → 503 + Retry-After (dedupe.lease,
    30s by default), a store that cannot answer → 503 + Retry-After: 5
  → Publish each record to NATS JetStream (ingest.{tenant}.{table}), a deduped
    one under its idempotency key
  → Commit the published ids in one call; on a failed publish, commit the
    records before it and release the rest (a publish whose outcome is unknown
    keeps its claim until the lease lapses)
  → 200 OK returned immediately
  → (If the tenant's NATS stream is full, or not open: 503 + Retry-After header,
    the id released)

Ingest worker pipeline (StartIngestWorker):
  ← JetStream pull consumer (buffer-consumer), one durable per tenant stream
    (ingest.{tenant}.>), delivered into one handler
  → Parse the event envelope (an envelope the worker cannot read — malformed JSON,
    an unknown or absent format, columns and row that don't pair — is parked on the
    DLQ, or acked-and-dropped where the DLQ is off for the table; either way
    counted by wavehouse_ingest_poison_total under its disposition)
  → Batch events per tenant table
  → At flush, a batch whose tenant has no ClickHouse connection is not inserted
    (parkBatch takes it to the DLQ switch whole); otherwise bulk INSERT to ClickHouse
    (INSERTs pin date_time_input_format=best_effort — the server default since
    ClickHouse 26.5; see /ingest-pipeline for the basic-vs-best_effort divergence)
  → On success: DoubleAck messages
  → On failure ClickHouse could not take (down, overloaded, read-only, denied —
    chconn.Classify): NakWithDelay the batch under the pool's backoff (the table's, for a
    table-scoped code); never DLQ. A multi-row batch refused for its size
    (chconn.Splittable) is split row by row first
  → On failure ClickHouse rejected: re-insert row by row; each row rejected again → DLQ output (dlq.{tenant}.{table}), then Ack to prevent infinite retry

  (Insert-only pipeline. The wire format `EventMessage` carries only
  {table_name, scope, received_timestamp, format, columns, row}; non-insert mutations
  DELETE/UPDATE/TRUNCATE/DROP/etc. must go through POST /v1/ops/query (or an
  operator-authored write pipe) — the /v1/ops/* RequireAdmin gate rejects
  non-admin callers at the API layer, so
  a no/invalid-token request (resolved to default_role, not admin in a
  production config) cannot reach the proxy.)

Active Sweeper (async goroutine, every 60s, in the process holding the sweeper lease), on each tenant's stream:
  → Read buffer consumer's AckFloor (highest contiguous ACKed seq)
  → Binary search for first message within that tenant's own gap window
    (a rejected tenant's as its folder last had it, unbounded for one rejected since boot; none for a removed tenant)
  → Purge target = MIN(ack_floor + 1, gap_window_seq)
  → Purge all messages below target from JetStream
```

### Query Path

```text
Client POST /v1/ops/query
  → JWT auth middleware (always runs, never rejects; a bad token yields an
    empty role and stashes its verification error for the denying gate)
  → refuseUnverifiable (503 + Retry-After: 30 for a token sent while tenant 0's
    JWKS has not been fetched yet — the ops tree resolves no tenant; tokenless
    and operator-key requests pass through)
  → policy.ResolveRole (empty role → default_role — the one sanctioned
    roleless exception)
  → /v1/ops RequireAdmin (resolved role == policy.admin_role, or the
    operator-key bit) — single gate shared with the rest of /v1/ops/*
    (pipes inspection, settings reload, schema discovery, DLQ stats). A denial is
    401 when a stashed error shows the caller presented an invalid token,
    else 403. Raw SQL has no per-statement scope check (a full SQL parser
    would be needed to authorize predicates), so the role gate is the
    entire authorization story. /v1/ops/query is the only surface for
    ad-hoc non-SELECT statements (DELETE/UPDATE/TRUNCATE/DROP/ALTER/…);
    non-admin callers use `POST /v1/ingest?table={table}` or a write pipe
    that lists their role for writes, and the structured query endpoint or
    named pipes for reads.
  → Decode {"sql": "..."} from the request body.
  → POST the SQL verbatim to ClickHouse's HTTP interface at
    <scheme>://<host>:<httpport>/?default_format=JSON
       &date_time_output_format=iso&database=<db>
    Auth via X-ClickHouse-User / X-ClickHouse-Key headers.
    Bound by a clickhouse.query_timeout context derived from the inbound request — client
    disconnect cancels the upstream call.
  → ClickHouse parses the SQL natively and decides what to do:
    → Read: returns 200 + {"meta":[...], "data":[...], "rows":N,
      "statistics":{...}} as JSON. The handler extracts `data` and
      forwards just that array, preserving the [{...}, {...}] response
      shape callers expect.
    → Mutation/DDL: returns 200 + empty body. The handler emits `[]` so
      response shape stays "always an array."
    → Error: returns 4xx/5xx + plain-text error message + the
      X-ClickHouse-Exception-Code header. The handler classes it by
      that code (chconn.Classify), not by the HTTP status ClickHouse
      uses for nearly everything: bad SQL → 400, a missing grant →
      403, bad credentials → 502, an outage → 503 — with the trimmed
      message, a `code` and `retryable` in the JSON error envelope.
  → Response carries Cache-Control: no-store so no downstream layer
    (browser, CDN, corp proxy) caches the result.
```

The proxy-pattern wins are: zero classification logic on the WaveHouse side (no `IsMutation` heuristic to maintain), and any ClickHouse statement type — including verbs added in future versions and inline FORMAT overrides — works without WaveHouse code changes. Multi-statement input (`SELECT 1; TRUNCATE t`) is supported when the upstream ClickHouse has multi-query enabled, which is the default on recent versions; older or restrictively-configured servers will return a clear error from ClickHouse itself for the second statement. The proxy buffers the response in memory with a 64 MiB cap (502 with `clickhouse response exceeded N bytes` on overflow, to keep a runaway `SELECT *` from pinning RAM on the API server), and passes ClickHouse's `Content-Type` through when an inline `FORMAT` directive overrides the default JSON envelope. The structured query endpoint and pipes still go through `clickhouse-go`'s native driver (Query/Exec) for performance and to keep the cached row-array shape consistent.

### Streaming Path

```text
Client GET /v1/stream
  → Tenant resolution: X-Tenant-ID → settings.Registry → the request's *settings.Store
    (absent = tenant 0; 400 malformed / 404 unknown / 503 rejected, before auth)
  → JWT auth middleware (always runs; token optional)
  → refuseUnverifiable (503 + Retry-After: 30 for a token sent while the
    tenant's JWKS has not been fetched yet; tokenless and operator-key
    requests pass through)
  → Announce the caller's projected column list as an `event: schema` frame
    (no `id:`, so it never moves Last-Event-ID) BEFORE registering, so a client
    on a quiet table learns its columns before any row arrives and a live event
    during setup finds the announcement already recorded rather than repeating
    it. This announcement is best-effort — skipped when the registry has no
    schema for the table yet, or the role cannot read the table at all (a role
    that CAN read it but whose column rules leave nothing readable is still
    announced, with an empty list) — and what actually guarantees a row is
    never sent without its names is the LIVE path's per-connection drift check
    on every event
  → Register a Subscriber with the Stream Hub, keyed by (topic, role)
  → If ?since= / Last-Event-ID provided:
    → Create ephemeral NATS consumer with DeliverByStartTime
    → Send historical events (projected per-connection) first
  → Live events: MQ → Hub.Broadcast → projected & serialized ONCE per role
    → fan the finished frame to every Subscriber of that (topic, role);
      a role carrying a row-level filter delivers per subscriber instead:
      the shared frame goes only to subscribers whose JWT claims admit
      the row (RowVisible)
    → a connection whose column list drifts (a schema change mid-stream) is
      re-announced before the next row, per connection
  → Live and replay track that drift in SEPARATE state and do not reconcile
    it (#543): replay keeps its own last-announced signature while the live
    path recorded one at subscribe time, so a connection that gap-fills
    ACROSS a column change can then receive live rows with no fresh
    announcement. A client that checks arity drops the mismatched rows; a
    same-length change (RENAME COLUMN, or a drop paired with an add) zips
    under the wrong names until it reconnects
  → Handler drains keepalives + event frames from one byte-pump → client
  → Policy filtering (historical + live): denied tables skipped, denied
    columns stripped, row filter evaluated per subscriber against claims.
    Column projection runs once per role (Hub.Broadcast) — per-subscriber
    work only where a row filter makes visibility per-connection; replay
    shares the same column policy + row check but projects per-connection
```

## Technology Stack

| Component | Technology | Purpose |
| --------- | ---------- | ------- |
| Language | Go 1.26 | Core runtime |
| HTTP Router | Chi v5 | Request routing and middleware |
| Authentication | golang-jwt v5 + keyfunc v3 | JWT (HMAC + JWKS) parsing and validation |
| Analytics DB | ClickHouse | Primary data store + schema source of truth |
| Message Queue | NATS + JetStream | Durable event streaming |
| L1 Cache | Ristretto v2 | In-process memory cache |
| Shared Cache | [rueidis](https://github.com/redis/rueidis) | Redis-compatible client for the shared backend (`cache.backend: redis`) |
| Embedded KV | Pebble | Optional deduplication |
| Config | cleanenv | YAML + env var config loading |
| Release | GoReleaser | Cross-platform binary builds |
| Containers | Docker (distroless) | Minimal production images |