Skip to content

feat(typelayer)!: validate, coerce and filter through chtypes - #712

Draft
EricAndrechek wants to merge 95 commits into
mainfrom
chtypes-v2
Draft

EricAndrechek wants to merge 95 commits into
mainfrom
chtypes-v2

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Summary

WaveHouse stops modelling ClickHouse itself. Validation, coercion, DEFAULT substitution, row-level security and insert checks now run through chtypes (github.com/wave-rf/chtypes/go v0.5.1, ABI revision 6). chtypes is a cgo dlopen of a per-ClickHouse-version library that runs the server's own parser and analyzer in-process. /v1/query and pipes ask ClickHouse to render their results. The Go that approximated ClickHouse is deleted.

This redoes #589 on current main, which is now multi-tenant. #589 stays open as a draft reference on the old main.

Fixes #387 (timestamp filters and time_range shifted on non-UTC columns), fixes #516 (the TS SDK's client-side stream filters compared timestamps as text), fixes #547 (the dedupe walkthrough posted a column clicks lacks), fixes #558 (discovery now warns on tables without DDL; the second half is moot because the type layer reconstructs column declarations from system.columns and never reads create_table_query).

Breaking, by design:

  • cgo is unconditional; there is no pure-Go build.
  • Release platforms narrow to linux/amd64, linux/arm64 and darwin/arm64, and Linux binaries need glibc ≥ 2.34.
  • A process with the api role refuses to start when no chtypes artifact is installed.
  • Several documented behaviours become "whatever ClickHouse does". They are listed below.

What changed

Type layer (internal/typelayer). One process-wide engine holds one lazily opened chtypes registry. Each tenant gets its own table set, bound from that tenant's schema refresh before the registry reports loaded and forgotten when the tenant is removed or moves.

  • Pool size: each base table compiles one handle and grows on demand up to min(GOMAXPROCS, 8); each role shape grows the same way up to min(GOMAXPROCS, 4).
  • Refusing a tenant: a tenant is refused on its own, and every other tenant keeps working, when:
    • its ClickHouse line has no installed artifact, or
    • its server time zone differs from the zone this process already opened that line with. chtypes fixes a library's zone at first open, so one process serves one zone per line.
  • Roles: only api-role processes load chtypes. Ingest-worker and sweeper processes need no artifact.
  • Compile profile: carries ClickHouse's type gates, so tables with LowCardinality(<integer>), long FixedString and Variant columns ingest and filter.

Ingest. The request body goes to chtypes as-is: one RowsExportWith call per body, with the role's check clauses as a row filter. Each record's parse outcome and check verdict come back together, so there is no Go decode, no re-encode and no second parse.

  • Published bytes: the published row is ClickHouse's own writer output, carrying the inserting role's wire columns.
  • Formats: Content-Type declares the format:
    • the JSON family;
    • text/csv and text/tab-separated-values, with RFC 4180's header parameter mapped three ways: present → CSVWithNames/TSVWithNames, absent → positional with header detection off, no parameter → ClickHouse's default detection.
  • Dedupe: main's windowed reserve/publish/commit is unchanged. A null or empty id cell counts as missing.

Errors. Bodies keep main's string code class. ClickHouse's numeric code travels as exception_code, both on a whole-request parser refusal (code: "clickhouse.rejected") and on each per-record result.

Query path. /v1/query and pipes go over HTTP to the tenant's target with FORMAT JSONEachRow. Failures are typed chconn.HTTPErrors, so the per-class error mapping is unchanged.

  • Fixed settings on every read:
    • wait_end_of_query=1
    • http_write_exception_in_output_format=0
    • a server-side max_execution_time
    • cancel_http_readonly_queries_on_client_close=1
    • readonly=2
    • pinned JSON rendering settings, including date_time_output_format=iso so DateTime is RFC 3339 UTC on every surface
  • Write pipes: keep IsMutation routing; they are never cached.
  • Cache key: carries a rendering marker, so old and new builds never share an entry.
  • Connections: each tenant's HTTP connections are capped at its max_open_conns.

Timestamp filters. Before this change, an RFC 3339 filter value and the time_range bounds were rewritten in Go to zone-less UTC text, and ClickHouse read that text in the column's zone. On any non-UTC column, results were off by the offset. Measured with the server in Europe/Berlin: a DateTime('Asia/Tokyo') column matched nothing, and a DateTime64(3,'America/New_York') column matched the row 4 hours late.

  • Fix: ClickHouse now parses the value itself: col OP parseDateTime64BestEffort({p:String}, 8[, '<column zone>']).
  • Verified on 26.8 and 24.8: every column returns the right rows, the primary key is still used, and an unparseable value is a 400.

in lists. They travel as ClickHouse external-data tables in a multipart body instead of as URL parameters, which ClickHouse caps at about 128 KiB per value. A 1.26 MiB, 60,000-element list goes through, and the primary key is still used through IN (SELECT …).

Integer claims. A claim bound as {p:String} and compared with an integer column wraps modulo 2^64 (at their own width for 128/256-bit columns), on the server and in chtypes alike. Row filters, insert checks and the /v1/query policy predicate now compare integer columns against a round-trip strict cast:

if(toString(accurateCastOrNull({p:String}, 'T')) = {p:String}, accurateCastOrNull({p:String}, 'T'), NULL)
  • In range: a canonical claim answers exactly as before and keeps the primary key in use.
  • Out of range or non-canonical: a claim like 007, +5, 1.0 or ≥ 2^64 matches no row and fails an insert check with 403.

Stream. Row filters run through chtypes over the published bytes.

  • Narrower rows: a row published by a column-restricted role is evaluated with its own column list.
  • Absent columns: a filter over a column the event doesn't carry withholds the row for that reader. The server computes that column again at insert, so judging the stream's copy could let through a row /v1/query excludes.
  • Withheld-row metric: labelled by reason (filter, error, decline, unavailable, drift).

Build, CI and release.

  • CGO_ENABLED=1 everywhere, go 1.27, golangci-lint v2.13.2.
  • glibc builder plus distroless/cc runtime that bakes the pinned artifact (chtypes.lock, scripts/fetch-chtypes.sh --frozen).
  • Native-runner release builds per target.
  • CI, dev and test ClickHouse move from 26.6.3.62 to 26.8.15.10. chtypes has retired 26.6, and 26.8 is the longest-supported line. The lock pins the exact matching 26.8 build.

Deleted:

  • Go-side validation and timestamp canonicalization (internal/discovery/{validation,timestamp}.go).
  • The Go row-filter and numeric code (internal/policy/{rowfilter,numeric}.go, LiteralValue, CanonicalNumericLiteral, RowVisible).
  • The compact encoder (internal/ingest/compact.go).
  • The native-driver result transformer.
  • Every CGO_ENABLED=0 path.

Size, measured. Production Go (non-test .go outside tests/) goes from 35,889 to 39,145 (+3,256). The migration trades hand-written ClickHouse modelling for a wrapper around the real thing, and adds per-tenant lifecycle, the HTTP reader and external-data in lists; it does not shrink the codebase lines.

Documented contract changes (please review these first)

surface before after
denied insert column 403 column "x" not allowed for insert 400, exception_code 117 (does not reveal whether the column exists)
unknown field / ALIAS / MATERIALIZED supplied WaveHouse wording 400, exception_code 117
EPHEMERAL column supplied accepted accepted where the format names columns (JSON, *WithNames) and a DEFAULT reads it; otherwise 400/117 (positional CSV/TSV carry wire columns only)
_eq check value supplied for a column the role may not write 403 accepted when it equals the claim; injected when omitted
role whose column policy cannot be compiled for the table n/a 500 retryable:false, no Retry-After; cause logged
JSON array body with content after the closing ] the tail was ignored 400
JSON array with an empty element ([a,,b], leading or trailing comma) accepted 400 invalid json
a body whose records ClickHouse's reader cannot separate (e.g. a malformed UUID swallowing the next records) n/a every counted record 422, nothing published (ClickHouse itself would silently store a subset under input_format_allow_errors_ratio)
parse error and check failure on one record 403 the 400 with ClickHouse's code
out-of-range or non-canonical integer claim on an integer column wrapped, or matched matches no row on a read; 403 on an insert check
text/csv / text/tab-separated-values ingest 415 accepted, with the three-way header mapping
unparseable timestamp on ingest accepted at the edge, failed at the worker 400 with ClickHouse's code at the edge
bare JSON number in a DateTime64 column read in the column's units whatever the server does; on 26.8 that is epoch seconds (epoch milliseconds clamp to 9999-12-31)
unavailable tenant on ingest n/a 503, Retry-After: 5, generic body; cause in logs only
/v1/query and pipe rendering Go json.Marshal: alphabetical keys, Decimal as string, NaN/Inf fail the request ClickHouse JSONEachRow: SELECT order, Decimal as number, NaN/Inf as null; DateTime stays RFC 3339 UTC (date_time_output_format=iso), now at the column's scale (.000Z, not trimmed)
/v1/query and pipe response over 64 MiB uncapped 502 clickhouse.response_too_large
/v1/query null filter value silent empty result 400
/v1/query timestamp filter / time_range on a non-UTC column off by the zone offset the exact instant
/v1/query in element that isn't a value of the column's type 400 matches no row
row-filtered SSE reader whose filter uses a column the inserting role can't write Go evaluated the padded row withheld (decline)
SSE / wire timestamps RFC 3339 UTC, trailing zeros trimmed RFC 3339 UTC rendered by ClickHouse at the column's scale; events published before the upgrade replay in the old spelling

Verification

  • make ci passed on this exact tree: Go unit 3,635 (-race), integration 532 + 101, e2e 95 (1 skipped), TS SDK 246, Go total coverage 94.8%. No threshold was lowered.
  • Stream-vs-query differential: 8,838 cells, comparing stream row-filter verdicts with the production /v1/query answer. It covers every column shape × operator × claim, including hostile spellings and integer claims at and beyond every width. Zero disagreements.
  • Live filter-value test against ClickHouse 26.8.15.10 and 24.8.14.39 covering timestamp columns in three zones, time_range, and an external-data in list. It fails if the zone or the cast is removed.
  • Tenancy tests:
    • two tenants on one line with different zones: only the second is refused;
    • a missing line affects only that tenant;
    • Forget never blocks a reload hook;
    • binds racing tenant removal never resurrect a removed tenant.
  • An ingest-only process boots with no artifact installed, and an api process refuses to.
  • Role-shape compiles run outside the role cache's lock (300 distinct _eq claims on one table: 113 µs → 19 µs per request) and every parser handle keeps the full filter cache (600 distinct subscriber claims: 22.9 µs → 3.4 µs per evaluation); both are pinned by benchmarks in internal/typelayer.
  • The repo's pre-push reviewers (code and docs, fresh context each round) were run to ship_it on this tree.

Known limitations (waiting on chtypes)

  • A table whose column has a volatile DEFAULT such as generateUUIDv4() declines every record that omits that column (422, the whole batch), because chtypes declines non-deterministic expressions rather than guess them; on main ClickHouse generated the value at insert. Measured on the 26.8 artifact. Waiting on chtypes to generate volatile defaults (tracked upstream).
  • Float NaN/±Inf read back as null on /v1/query and pipes but as the strings "nan"/"inf"/"-inf" on the stream, because chtypes' export ignores output_format_json_quote_denormals. Documented; waiting on the upstream parity fix.
  • A table CHECK whose expression is non-zero but not exactly 1, or not UInt8 (e.g. CHECK x with x = 2) is accepted at ingest but refused by the server at insert (469 / code 1). The worker parks the row, but the stream may already have delivered it. Write CHECKs as explicit comparisons (x > 0). Waiting on the upstream fix.
  • A malformed UUID in a batch declines the whole batch (422): ClickHouse's reader swallows the records after it, and WaveHouse refuses rather than report a short batch. Waiting on chtypes to report swallowed records (tracked upstream).

First-run risks on GitHub

  • artifacts.wavehouse.dev is a CI dependency on a cold artifact cache. The channel is append-only, so the locked build stays fetchable.
  • First use of ubuntu-24.04-arm and macos-latest (advisory release-validation job).
  • CodeQL's default setup builds go1.27 with cgo (not a required check).

Follow-ups (not in this PR)

  • On ClickHouse lines before 26.5, a policy claim in RFC 3339 form compared with a DateTime column is refused (code 53); 26.5 and later accept it because cast_string_to_date_time_mode defaults to best_effort (measured on 26.8.15.10). Parsing claims explicitly would need the /v1/query and stream renderers changed together.
  • /v1/ops/query does not pin wait_end_of_query=1 / http_write_exception_in_output_format=0 like the other read paths, so a statement failing mid-stream can return a 200 with truncated JSON (unchanged from main).
  • select_all for a column-restricted role expands to every schema column, EPHEMERAL included, which ClickHouse refuses to SELECT (inferred, unchanged from main).
  • A READONLY (code 164) refusal on /v1/ops/query and write pipes is still classed as unavailable (503, retryable), unchanged from main; the read paths map it to 502 clickhouse.misconfigured.
  • Serving several server time zones on one ClickHouse line in one process needs one registry per zone over a real artifact copy. Trigger: the first tenant refused for a zone mismatch.
  • Share compiled tables across tenants on the same (address, database). Trigger: tenants × tables × pool memory over budget.
  • The ingest record-count guard takes the lower of two readings for a BOM-led CSV/TSV body whose first column is an Array, Map, JSON, Dynamic, Tuple or Variant holding a string, so a malformed UUID there can still yield a short batch; such first columns should be classified like String.
  • A caller's own /v1/query in elements on a 128/256-bit integer column go through plain accurateCastOrNull, which wraps at the column's width. Caller filters only narrow what policy admits, but the strict cast could cover them too.

🤖 Generated with Claude Code

EricAndrechek and others added 30 commits October 1, 2026 05:21
Pin the revision-6 26.6 artifact in chtypes.lock and fetch it with
scripts/fetch-chtypes.sh. Every build is cgo: a glibc builder and a
distroless/cc runtime that bakes the artifact, native-runner release
builds for linux/amd64, linux/arm64 and darwin/arm64, and the setup-env
action caches the artifact for the unit, integration and e2e jobs.
golangci-lint moves to v2.13.2 (the next go directive needs it), which
retires four nolint directives it no longer needs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
Ports the reference migration's documentation onto main's current
multi-tenant, multi-role text. Ingest validation and row-level security run
through one process-wide chtypes engine with a per-tenant table set, loaded
only by api-role processes; a tenant with no artifact for its ClickHouse
line, or a server time zone that differs from the zone that line was opened
with, is refused on its own. Error bodies keep the string code class and add
exception_code. /v1/query and pipes are rendered by ClickHouse. Platforms
narrow to linux/amd64, linux/arm64 and darwin/arm64 on glibc.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
Export the resolved row-filter predicate (Predicate) and hand it out
through ResolvedPermissions.Predicates, the same resolution the query
path renders, so the stream can evaluate it with ClickHouse's own engine.
ResolvedSelect.WhereSQL(colType) renders the clause with an integer
column's claims bound through the strict round-trip cast, so a claim that
does not fit the column matches nothing instead of wrapping. WhereClause,
WhereParams and RowVisible stay until their callers move.

chsql gains the shared {p:String} encoding (EscapeStringParam), the
strict cast (IntegerType, StrictInt, IntParam), and QuoteIdent now
escapes NUL and the control characters exactly as ClickHouse's
backQuote does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
A SchemaRegistry now runs OnRefresh hooks after each successful Refresh
publishes its schemas and before it marks the registry loaded, so a
reader that sees Loaded() also sees what the hooks built from the first
refresh. Overlapping refreshes run their publish and hooks as one step,
in publish order. The server's default zone name is stored with the
version and exposed as ServerTimezone. A refresh that finds tables
without a CREATE statement (the two system scans are not one snapshot)
warns once, naming them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
Add internal/typelayer, the one package that calls the chtypes SDK
(go/v0.4.0, which needs go 1.27). One process-wide Engine holds one lazy
registry and every tenant's compiled tables: Bind(tenant, version, zone,
tables) compiles a tenant's set, Table and RoleTable hand out read-locked
handles, and Forget drops a tenant with its handles closed in the
background so a caller holding a reload lock never waits on a request.

A missing artifact for a tenant's server line, a server zone that differs
from the zone its line was opened with in this process, or a table that
does not compile makes that tenant (or that table) Unavailable; every
other tenant keeps answering. A table compiles one handle at Bind and
grows its pool only when every handle is busy, up to min(GOMAXPROCS, 8);
role shapes keep one. A rebind closes the old role projections after
releasing the base table, so a request holding a projection can still
look the base table up.

SkipWithoutArtifact lets any package's tests skip without the artifact,
or fail under WAVEHOUSE_TEST_REQUIRE_CHTYPES=1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
…face

The structured query and named pipes now read through ClickHouse's HTTP
interface and serve ClickHouse's own FORMAT JSONEachRow rendering, framed
as a JSON array, instead of scanning rows through the native driver and
re-marshalling them in Go.

- Per-tenant Target and the TLS-aware client cache the ops proxy uses; a
  refusal is a chconn.HTTPError and a failure on the way wraps with %w, so
  the error classes keep working. An oversized response is typed and
  answers clickhouse.response_too_large.
- Every read pins the rendering settings and sends wait_end_of_query,
  http_write_exception_in_output_format=0, max_execution_time (the tighter
  of the role's cap and query_timeout), cancellation on client close and
  readonly=2. A READONLY refusal of a read is clickhouse.misconfigured.
- Write pipes run without readonly=2 and keep BYPASS, no-store and the
  never-retryable error answer. IsMutation stays, in sql_classify.go.
- Reads share one connection cap per tenant (MaxConns, default 100).
- The builder binds named {pN:String} parameters, an in list as one
  Array(String), refuses a null filter value and a value too large for
  the HTTP interface with a 400, and rewrites an RFC 3339 filter value
  only on a DateTime or DateTime64 column.
- The cache key opens with a rendering marker, so builds that render
  differently never share a Redis entry.

BREAKING CHANGE: /v1/query and pipe responses carry ClickHouse's
rendering: keys in SELECT order, DateTime as `YYYY-MM-DD hh:mm:ss[.fff]`
in the column's zone, decimals as numbers, NaN and Inf as null. A null
filter value is a 400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
…ished with

ParseRow accepted only the generation's full wire column list, so a row
published by a role that may write some columns could never be read. It
now accepts any duplicate-free subset of the wire columns, in any order,
and names that list on the parse (chtypes.WithColumns), so every unlisted
column holds the DEFAULT the server would store for it, computed with the
listed values in scope. A list naming an unknown or repeated column is
still ErrColumnsDrift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
The structured query's row filter now comes from WhereSQL with the
discovered column types: a policy claim on an integer column (any width,
Nullable or LowCardinality) binds as one {pN:String} parameter compared
through the strict round-trip cast, so a value that is not the canonical
spelling of an in-range integer matches nothing instead of wrapping.
Every other value keeps the plain {pN:String} binding, encoded by
chsql.EscapeStringParam, the same encoding the type layer's row filters
use.

The e2e suite pins the rendering of a Decimal and a DateTime64, RFC 3339
filter values on DateTime and DateTime64 columns, a timestamp read back
from /v1/query filtering as it is, and the 400 for a null filter value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
clickhouse.chtypes_registry (WH_CHTYPES_REGISTRY) names the chtypes
artifact directory the API role's type layer searches first; empty keeps
the SDK's own search path. It is a bound key, so boot's refusal of unbound
WH_* variables lets it through.

The ingest worker takes its parsing settings from typelayer.InsertSettings,
the map the API judges rows under, and pins async_insert=0 so a message is
acked only once its row is stored. Nothing else in the worker changes; its
test fixture builds rows by hand instead of through EncodeCompactRow.

The dev and quickstart ClickHouse move to 26.8.15.10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
The hub's row filter is now decided by ClickHouse's own parser and
expression engine through the tenant's compiled schema, instead of a Go
re-implementation of ClickHouse's comparisons over a name-keyed decode.

- RowEvaluator.Prepare(tenant, table, columns, row) parses each event once;
  the returned view answers per subscriber. NewRowEvaluator(engine) is the
  production evaluator; an unwired hub or a nil engine withholds every row
  of a row-filtered role instead of delivering it.
- Rows published with the inserting role's narrower column list are
  parsed under that list and evaluated, in any column order. A filter on a
  column the event does not carry withholds the row for that subscriber,
  since the stored row's DEFAULT is computed again at insert.
- wavehouse_sse_rows_withheld_total gains a reason label: filter, error,
  decline, unavailable or drift.
- The policy read stays per event during replay; the schema frame, Prune
  and tenant topics are unchanged.
- The SSE end-to-end test expects ClickHouse's DateTime64 rendering.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
Ingest hands the request body to the tenant's chtypes type layer in one
parse (Table.IngestWith through the role's projection) instead of decoding,
validating, checking, injecting and re-encoding records in Go. The verdicts
feed the existing windowed dedupe (reserve, publish, commit, release) and
the envelope carries ClickHouse's exported row with the role's wire columns.

- content_type.go (was record_reader.go): text/csv and
  text/tab-separated-values, with RFC 4180's header parameter three ways
  (present, absent, auto-detect); any other header value is a 415.
- ingest_framing.go: depth-1 comma reframing of a compact JSON array so one
  bad record does not cost its siblings, and the dedupe id read by position
  from the exported row. A null id cell is missing, like an absent one.
- Error bodies keep the string code class. ClickHouse's numeric code
  travels as exception_code: per record, and on a whole-request header
  refusal alongside code clickhouse.rejected.
- A tenant or table the type layer cannot judge is a 503 with
  Retry-After: 5, decided before the body is read, with a generic body and
  the cause in the log.
- Column policy is the role's compiled schema, so a denied column is
  ClickHouse's 117 (400) instead of a gateway 403; parse errors now precede
  check errors.
- The RecordValidator/InsertChecker seams are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
The SDK's InsertRecordResult gains exception_code, ClickHouse's numeric
error code on a per-record parser refusal; the string code class is
unchanged. The ingest, NDJSON, DLQ and batching suites now assert the
type layer's behaviour: CSV/TSV with the header parameter, a compact JSON
array that keeps its good records, a denied column as 117, a header
refusal as clickhouse.rejected with exception_code 117, an _in check
judged on the table default, and an unparseable row refused at ingest so
the DLQ never sees it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
An API-role process opens one typelayer.Engine at boot (the
clickhouse.chtypes_registry directory, else the SDK's search path) and
refuses to start without an artifact; an ingest-only or sweeper-only
process never opens it and boots with none installed. Each tenant's
schema registry binds the tenant from every successful refresh, attached
before its first one so a loaded registry is a bound one, and a registry a
reload retires forgets it without waiting on anything. Only the tenant's
current registry binds it: a refresh still in flight when its registry was
retired is dropped, and one already binding forgets again afterwards, so
a tenant a reload moved never keeps the previous database's tables and a
removed tenant's do not come back. The engine is released after schema
discovery, after the HTTP drain.

The ingest handler judges with the engine, the stream hub evaluates row
filters with stream.NewRowEvaluator over it, and pipes and structured
queries cap their HTTP connections at the tenant's max_open_conns.

Tests that boot the api role skip without the artifact, or fail under
WAVEHOUSE_TEST_REQUIRE_CHTYPES=1; the test settings close the HTTP port
too, so a ClickHouse on the developer's :8123 cannot answer them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
The 26.6 line gets no further chtypes builds, so the lock moves to the
26.8 LTS line: 26.8.15.10-lts, build b1790845279, on darwin-arm64,
linux-amd64 and linux-arm64, written by the v0.5.1 CLI as lock schema 2
(every consumer of the lock is on v0.5.1). scripts/fetch-chtypes.sh
fetches line 26.8 with the same SDK version as go.mod, and CI and the
e2e orchestrator pin clickhouse/clickhouse-server:26.8.15.10, the patch
the artifact is built from. ABI revision 6 and the abi6 cache path are
unchanged.

The 26.8 build fills a skipped row's VerdictCode/VerdictErr, reports
RowsPassed 0 for a rejected batch and sets ExportDeclined on a zero-row
refusal, where every 26.6 build does not. The type layer already gates
on Outcome and reads ErrCode/ErrMsg, which are right on both; the test
that pinned the old RowsPassed now pins only what Ingest must do, and
the comments say which builds behave which way.

TestNewEngine_OpensNoLibraryAtConstruction now shadows the test line
itself and releases a table it did not expect to get, so a passing
lookup fails the test instead of deadlocking Close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
chtypes.Timezone is superseded in SDK v0.5.1, and a direct write races
the SDK's own read when another library opens. openLine now sets the
zone with SetDefaultTimezone under the lock every open takes, so the
zone an open reads is the one set for it. WithTimezone does not fit one
registry built at boot, before any server has reported its zone, that
then opens each line in the zone of its first tenant.

The zone record is keyed on the opened library's path, the unit the SDK
initialises once per process. A second registry that asks for a line
already open in another zone gets the SDK's own, untyped refusal; it is
recognised from the record and reported as the same per-tenant cause,
with the SDK's words kept.

The registry is asked for the server's line, as v0.4.0 resolved it, so
every tenant on a line shares one library whichever patch its server
runs. Which artifact answers a tenant is logged once each time the
tenant's library changes, as a warning when the server runs another
patch. Anything the SDK would print on stderr goes to the process log
through its Progress writer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
A table with a LowCardinality(UInt64) column exists on a server only
because its CREATE passed allow_suspicious_low_cardinality_types, but
the type layer compiled it without that gate, so chtypes refused every
record with code 455 and every filter over the table declined. A
FixedString wider than 256 and a Variant of similar types failed the
same way with code 44.

The compile profile now carries the ten type gates that the 25.8, 26.6
and 26.8 artifacts all accept. Every table compiled here already exists
on the server, so admitting its types changes no verdict a server would
give.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
Policy-driven tests now run against the wired app the way an operator
drives it: withPolicy writes roles.json and policies.json into the
settings directory and reloads it through the ops route, and bearer mints
a token for the role under the app's JWT secret. default_role stays the
admin role, so the rest of the suite is unchanged.

Ported onto it: the stream-vs-query row-filter differential, every query
now through the production /v1/query as its own role and every stream
verdict from the hub's evaluator over the app's own type layer, with a
check that the query side admits something; CSV, TSV and their WithNames
forms landing in ClickHouse; a compact array with one bad record; an
unknown header refused as 400 with exception_code 117; the published row
being the stored row; filter values round-tripping both bindings; the
type-rendering pin; a denied column refused per record with 117; an
injected check column; an _in check judged on the table default; an
integer claim that does not fit refused. The resource-cap test runs
through the same harness, and a role's time cap is pinned through
/v1/query against a slow view instead of through the native driver,
which no read path uses now.

Dropped with their subject: the Go row-filter narrowing and timestamp
canonicalization differentials. The identifier fixtures quote names the
way ClickHouse's backQuote does, control characters included. The suite's
ClickHouse moves to 26.8.15.10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018uEnYtmudjD1nn3T44zuhB
@coderabbitai

coderabbitai Bot commented Oct 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 01aefaf0-2d34-4c05-aa14-00be5830b5f8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file github_actions Pull requests that update GitHub Actions code go Pull requests that update go code area/observability Metrics, logs, traces, health, profiling area/api HTTP handlers, routing, middleware area/ingest Ingest pipeline (Bento, batching, DLQ) area/query Structured query AST, SQL builder area/policy Access control policies (Hasura-style) area/pipes Named query pipes area/sdk TypeScript SDK (clients/ts/) area/docs Documentation, site/, README area/infra CI, build, deploy, Docker, release area/app Process wiring (internal/app): component build, run, release labels Oct 1, 2026
This was referenced Oct 1, 2026
@github-actions

github-actions Bot commented Oct 1, 2026

Copy link
Copy Markdown

📚 Docs preview is live → https://3f66f5e1-wavehouse-docs.wave-rf.workers.dev

  • Commit — a318830: fix(ingest): read BOM- and form-feed-led bodies the way ClickHouse does
  • Author — @EricAndrechek, Claude Opus 5.5
  • Committed — 2026-10-01 14:30 (UTC-04:00)
  • Deployed — 2026-10-01 14:51 EDT

@github-code-quality

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: Go

Go

The overall line coverage in commit a318830 in the chtypes-v2 branch is 92%. The line coverage in commit 32e03a0 in the main branch is 93%.

Show a line coverage summary of the most impacted files.
File main 32e03a0 chtypes-v2 a318830 +/-
internal/typelayer/ingest.go 0% 79% +79%
internal/api/cl...ckhouse_http.go 0% 88% +88%
internal/typelayer/records.go 0% 90% +90%
internal/typelayer/filter.go 0% 91% +91%
internal/typela...er/roletable.go 0% 93% +93%
internal/api/sql_classify.go 0% 93% +93%
internal/typela...er/typelayer.go 0% 94% +94%
internal/query/bind.go 0% 95% +95%
internal/api/content_type.go 0% 95% +95%
internal/api/ingest_framing.go 0% 98% +98%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api HTTP handlers, routing, middleware area/app Process wiring (internal/app): component build, run, release area/docs Documentation, site/, README area/infra CI, build, deploy, Docker, release area/ingest Ingest pipeline (Bento, batching, DLQ) area/observability Metrics, logs, traces, health, profiling area/pipes Named query pipes area/policy Access control policies (Hasura-style) area/query Structured query AST, SQL builder area/sdk TypeScript SDK (clients/ts/) dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation github_actions Pull requests that update GitHub Actions code go Pull requests that update go code

Projects

Status: Backlog

1 participant