chtypes 0.1.1

ClickHouse's own type system, schema validation, DEFAULT/TTL semantics and coercion, per ClickHouse version, over the frozen chs_* C ABI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
# chtypes — Rust SDK

ClickHouse's own type system, schema validation, DEFAULT/TTL semantics and
coercion — per ClickHouse version, in one process. A peer SDK over the
`chs_*` C ABI (pre-1.0 only the names and the format integers are frozen —
signatures can still change by deliberate cycle, which is why the loader
checks `chs_abi_revision` before trusting them), alongside Go, Python and
TypeScript. `../../spec/` is the
contract; where this crate and the spec disagree, the spec wins.

`cargo doc --no-deps --open` is the full reference — every public item is
documented (`#![deny(missing_docs)]`), including which `Error` variant each
call can produce and what each one means.

## Install

```toml
[dependencies]
chtypes = "0.1"
```

Unix only — the loader is `dlopen`. To work against a checkout instead,
consume it as a path dependency:

```toml
[dependencies]
chtypes = { path = "../chtypes/rust" }
```

**Artifact prerequisite.** The crate answers nothing by itself: it `dlopen`s
one artifact per ClickHouse release (166–302 MB each — real ClickHouse,
compiled). Get them with the crate's own command (see
[Fetching artifacts](#fetching-artifacts) below),

```sh
cargo install chtypes           # the `chtypes` binary
chtypes fetch 25.8              # verified, into the per-user cache
```

with `scripts/fetch.sh 25.8` (the reference implementation, same result), or
build one in the core repository; all land under
`~/.cache/chtypes/artifacts/<os>-<arch>/<minor>/` — the per-user cache
`Registry::from_env_or_default()` resolves to — so a registry looks like:

```
<registry>/
  24.8/  manifest.json  libchtypes_s1.so  unsafe_families.txt
  25.8/  manifest.json  libchtypes.dylib  unsafe_families.txt
  ...
```

**How loading works, and what the loader checks.** `Registry` scans one
subdirectory per version. The shared library's file name comes from
`manifest.json`'s `library` field — never constructed from the platform (the
Linux artifacts still ship the historical `libchtypes_s1.so` name). Each
library is `dlopen`'d with `RTLD_NOW | RTLD_LOCAL`, names itself via
`chs_clickhouse_version()` (nothing is inferred from the directory), and is
cross-checked against the manifest's size and version fields. The loader then
compares the artifact's `chs_abi_revision()` against this crate's hand-kept
`chtypes::ABI_REVISION`: a **different nonzero** revision is refused at load
(`Error::Load` — calling through mismatched declarations is undefined), while
`0` means the artifact predates the probe and loads with per-symbol
degradation (missing entry points answer `Error::PredatesFeature` at call
time, never at load).

## Quickstart

```rust
use chtypes::{Format, Registry, NO_SETTINGS};

let registry = Registry::from_search_path();             // docs/fetch.md §1: $CHTYPES_REGISTRY, the per-user cache, the system locations
let lib = registry.for_version("25.8")?;                // minor line or exact patch; Error::ArtifactMissing if nowhere
let schema = lib.compile("ts DateTime, seq UInt8")
    .settings([("flatten_nested", "1")])                // the deployment's profile
    .compile()?;

// A good row — accepted, but silently coerced: 256 into UInt8 stores 0.
let ok = schema.rows(Format::JsonEachRow,
    br#"{"ts":"2026-01-15 10:30:00","seq":256}"#, NO_SETTINGS)?;
assert_eq!(ok.outcome.to_string(), "accepted");
assert_eq!(ok.rows[0].values[1].text, "0");
assert_eq!(ok.transformed[0].reason, chtypes::reason::OVERFLOW_WRAP);

// A bad row — rejected with ClickHouse's own code, in the Ok value.
let bad = schema.rows(Format::JsonEachRow, br#"{"ts":"not a date","seq":1}"#, NO_SETTINGS)?;
assert_eq!(bad.outcome, chtypes::Outcome::Rejected);
println!("[{}] {}", bad.err_code, bad.err_msg);          // ClickHouse's message
```

**The verdict is in the `Ok` value.** A row the server would reject is `Ok`
with `Outcome::Rejected`; the `Err` arm is for the machinery (loading,
marshalling, an unreadable document). Three outcomes must never be conflated:
**rejected** (the server itself would refuse — a real ClickHouse code),
**unsupported** (this build declines to guess — `Error::Unsupported` /
`Outcome::Unsupported`, code `-2`; fall back to the server), and **accepted**,
possibly with coercions reported in `transformed` and volatile DEFAULTs in
`substituted` (send those columns explicitly in the INSERT).

## Fetching artifacts

`docs/fetch.md` is the contract, identical in the four SDKs; this crate
implements it behind the Cargo feature **`fetch`** (on by default —
`default-features = false` drops the command, `ensure`, autofetch and every
dependency they bring, leaving the loader alone).

**The command.** `cargo install chtypes` puts a `chtypes` binary on the path:

```
chtypes fetch <line>... [--all] [--platform <os-arch>] [--dest <dir>]
                        [--tag <t> | --url <base>] [--lock <file>] [--frozen]
                        [--force] [--offline]
chtypes verify [--dest <dir>]        re-hash every installed line against its manifest
chtypes list   [--dest <dir>]        what is installed, and what the release offers
chtypes where                        the registry directory fetch would write to
```

Progress prints on stderr; `fetch` prints each installed directory alone on
stdout. Exit codes: 0 ok · 1 verification failed · 2 usage · 3 source
unreachable · 4 not published for this platform/line.

**The function.** `chtypes::ensure(line, &opts)` is the same operation from
Rust — idempotent: once the line is installed and its library hashes what the
signed release lists, nothing is downloaded (the release's three small files
are read, never the tarball; `offline: true` reads no source and trusts the
installed manifest):

```rust
use chtypes::{EnsureOptions, Registry};

let installed = chtypes::ensure("25.8", &EnsureOptions::default())?;   // AlreadyInstalled | Installed | Replaced
let registry = Registry::new(installed.dir.parent().unwrap())?;
```

`EnsureOptions` carries the flags (`dest`, `platform`, `url`/`tag`, `lock`,
`frozen`, `force`, `offline`, `progress`) and the trust policy
(`trusted_keys`, `allow_unsigned`; `None` reads the environment).

**Where.** Lookup walks `docs/fetch.md` §1 in order — an explicit directory,
`$CHTYPES_REGISTRY`, `${XDG_CACHE_HOME:-~/.cache}/chtypes/artifacts/<os>-<arch>`,
then `/usr/local/share/chtypes/artifacts/<os>-<arch>` and
`/opt/chtypes/artifacts/<os>-<arch>` — and takes the first directory holding
the line (`registry_search_path`, `locate`). Fetch writes to the first of the
first three that is set (`install_dir`), never to a system location. With an
explicit `--dest`, "already installed" means installed *in that directory*:
a container build's `--dest /opt/chtypes/artifacts` is never satisfied by
the builder's own cache.

**Verification, in order — nothing else is a verdict.** `SHA256SUMS.sig` must
verify (ed25519) over the exact bytes of `SHA256SUMS` under a trusted key;
`index.json` must agree with the signed sums about the asset; the tarball is
hashed as it streams and never unpacked on a mismatch; it is unpacked flat
(plain top-level files only — no paths, links or traversal) into a temporary
sibling, renamed into `<registry>/<minor>/`, and the installed library is
re-hashed in place. The release key is embedded (`fetch::RELEASE_PUBLIC_KEY`,
id `deb275922dbff76e`); `CHTYPES_TRUSTED_KEYS=<hex>[,<hex>…]` replaces it for
a mirror; `CHTYPES_ALLOW_UNSIGNED=1` skips the signature with one loud
warning naming the source — never the default, never silent.

**Pinning.** `--lock chtypes.lock` records, per `<os>-<arch>/<minor>`, the
asset and sha256 that were installed; `--frozen` (or
`EnsureOptions { frozen: true, .. }`) refuses anything else with
`CHTYPES_ARTIFACT_PINNED`.

**Lazy fetch on first open** is opt-in: `RegistryOptions { autofetch:
Some(true), .. }` or `CHTYPES_AUTOFETCH=1` makes
`Registry::from_search_path_with(opts).for_version(line)` run `ensure` first
for a line found nowhere — once per process per line, under one process-wide
lock, so concurrent opens fetch once. Off by default: a production process
must not begin a 250 MB download inside a request.

**The error.** A line found nowhere on the search path is
`Error::ArtifactMissing { line, platform, looked_in }`, whose message is the
one every SDK renders:

```
chtypes: no artifact for ClickHouse 25.8 (linux-arm64). Looked in: /home/u/.cache/chtypes/artifacts/linux-arm64, /usr/local/share/chtypes/artifacts/linux-arm64, /opt/chtypes/artifacts/linux-arm64.
Install it:  cargo install chtypes && chtypes fetch 25.8
or set CHTYPES_AUTOFETCH=1 to fetch on first use.
```

`Error::artifact_code()` answers the shared code string —
`CHTYPES_ARTIFACT_MISSING`, `…_UNTRUSTED` (`Error::ArtifactUntrusted`),
`…_CORRUPT` (`ArtifactCorrupt`, any hash mismatch), `…_PINNED`
(`ArtifactPinned`), `…_UNPUBLISHED` (`ArtifactUnpublished`) and
`CHTYPES_SOURCE_UNREACHABLE` (`SourceUnreachable`) — and `None` for
everything else. It is distinct from `Error::code()`, which stays the
ClickHouse error code of a rejection. A registry over one explicit
directory (`Registry::new`) keeps answering `Error::NoSuchVersion`, naming
what is loaded.

**Tests.** `cargo test --test fetch` runs every fixture in
`spec/fixtures/fetch/` (signed, bad signature, unsigned, tampered tarball,
sums/index mismatch, the lock) through both the library and the binary,
offline, reading the verdicts from the fixtures' own `expected.json`; it
skips loudly when the fixtures are absent.

## API reference

Every public item, with the `chs_*` entry point underneath it. "derived" means
the crate computes it with no C call.

| Item | C function | Takes | Returns | Errors |
|---|---|---|---|---|
| `Registry::new(dir)` / `from_env()` / `from_env_or(dir)` / `with_timezone(dir, tz)` | `chs_clickhouse_version` + `chs_abi_revision` + `chs_init` per artifact | registry directory; optional timezone (default `UTC`) | `Registry` (eager: every artifact in the directory) | `Registry`, `Load`, `NotAnArtifact`, `CorruptArtifact`, `VersionMismatch`, `Init`, `InitConflict`, `EmptyRegistry`; `NoRegistryEnv` (`from_env`) |
| `Registry::from_search_path()` / `from_search_path_with(RegistryOptions)` | — (loads nothing until asked) | optional explicit dir, timezone, `autofetch` (+ `fetch: EnsureOptions`) | `Registry` (lazy: the `docs/fetch.md` §1 search path, one line per open) ||
| `Registry::for_version(v)` | derived; lazy: the per-artifact load above, on first open | minor line or exact patch | `Arc<Library>` | eager: `NoSuchVersion` (names what IS loaded; no nearest fallback); lazy: `ArtifactMissing` (§7), or the fetch's own error under autofetch |
| `Registry::versions()` / `libraries()` / `dir()` / `search_path()` / `autofetch()` | derived || minor lines, numeric order (lazy: what is installed) / loaded libraries, release order (`Vec<Arc<Library>>`) / path / the directories looked in / the setting ||
| `ensure(line, &EnsureOptions)` (feature `fetch`) || line or exact patch; the flags of `chtypes fetch` | `Installed { dir, line, version, library, library_sha256, platform, action, asset }` | `ArtifactUntrusted`, `ArtifactCorrupt`, `ArtifactPinned`, `ArtifactUnpublished`, `SourceUnreachable`, `Fetch` |
| `fetch::ensure_all` / `verify_installed(dir)` / `release_info(&opts)` / `install_dir(&opts)` / `search_path(&opts)` / `parse_line` (feature `fetch`) || as `chtypes fetch --all` / `verify` / `list` / `where` | `Vec<Installed>` / `Vec<Verification>` / `ReleaseInfo` / path / paths / minor | as `ensure` |
| `fetch::TrustPolicy`, `LockFile`, `IndexRow`, `verify_signature`, `sha256_file`, `RELEASE_PUBLIC_KEY[_HEX]`, `RELEASE_KEY_ID` (feature `fetch`) || the pieces of the chain, for a host that wants them separately || `Fetch` (a malformed key list) |
| `registry_search_path(explicit)` / `search_path_for(platform, explicit)` / `install_dir[_for]` / `locate[_in]` / `installed_lines` / `host_platform` / `cache_dir_for` / `default_registry_dir` | derived || the §1 search path, where fetch writes, `<dir>/<minor>` of the first hit, what is installed, `<os>-<arch>` ||
| `Error::artifact_code()` | derived || the shared `CHTYPES_ARTIFACT_*` / `CHTYPES_SOURCE_UNREACHABLE` code, `None` otherwise ||
| `Registry::shutdown()` | `chs_shutdown` per library ||||
| `Library::load(path, tz)` | `dlopen` + the four mandatory symbols | artifact path, timezone | `Library` | `Load`, `NotAnArtifact`, `Init`, `InitConflict`, `Nul` |
| `Library::version()` / `minor()` / `path()` | `chs_clickhouse_version` (cached) || `"25.8.28.1-lts"` / `"25.8"` / path ||
| `Library::abi_revision()` | `chs_abi_revision` || artifact's revision, `0` = predates probe ||
| `Library::validate_type(expr)` | `chs_validate_type` | type expression | canonical spelling, ClickHouse's own text | `Schema` (rejected, e.g. 50), `Unsupported`, `PredatesFeature`, `Nul` |
| `Library::compile(ddl)` | — (builder) | column-declaration list (not a `CREATE TABLE`) | `CompileRequest` ||
| `CompileRequest::settings(...)` / `.mode(...)` | — (builder) | declared profile; `CompileMode::Declared` | `Self` ||
| `CompileRequest::compile()` | `chs_schema_compile` + column introspection group || `Schema` | `Schema` (server rejects: 115 unknown setting, 455/44 declared gate, 691 Enum DEFAULT, …), `Unsupported` (−2 declines), `Nul` |
| `Library::set_default_settings(&[(k,v)])` | `chs_set_default_settings` | process-wide seed; admission budgets live here | `()` | `Schema` (115, wholesale — nothing committed), `PredatesFeature`, `Nul` |
| `Library::reference_type(expr)` | `chs_reference_type` | type expression | `Option<String>` (`None` = no wider type) | `PredatesFeature`, `Nul` |
| `Library::registered_families()` | `chs_registered_families` || every type family in this build | `PredatesFeature` |
| `Library::function_flags()` | `chs_function_flags` || TSV volatility audit | `PredatesFeature` |
| `Library::has_compile_settings()` | symbol probe || `true` (mandatory symbol) ||
| `Library::shutdown()` | `chs_shutdown` || — (idempotent; required before any `dlclose`) ||
| `Schema::columns()` | `chs_schema_column_*` (read at compile) || `&[Column]`, canonicalised ||
| `Schema::library()` | derived || `&Arc<Library>` ||
| `Schema::set_engine(engine, order_by, mt_settings)` | `chs_schema_engine` | `SHOW CREATE` spelling, sorting key, MergeTree-namespace settings | `()` | sign rule: `Schema` (rc>0, server refusal — 115), `Unsupported` (rc<0, decline), `PredatesFeature`, `Nul` |
| `Schema::set_ttl(sql)` | `chs_schema_ttl` | table-level rows TTL | `()` | `Unsupported` (any nonzero), `PredatesFeature`, `Nul` |
| `Schema::row(format, raw)` / `row_with_settings(...)` | `chs_row` | format code, counted bytes, settings | `RowResult` (verdict inside) | `PredatesFeature`, `BadDocument`, `Nul` |
| `Schema::rows(format, body, settings)` | `chs_rows` | one request body = one clock instant | `BatchResult` (verdict inside) | `PredatesFeature`, `BadDocument`, `Nul` |
| `Schema::compile_filter(expr, params)` | `chs_filter_compile` | one boolean expression; `params` binds `{name:Type}` positionally (`NO_PARAMS` for none — values are STRINGS, never hand-escaped) | `Filter<'_>` (borrows the schema — the free order is a compile-time fact) | `Schema` (47 unknown identifier, **456** unbound param, **457** unparseable param value), `Unsupported` (clock reads), `PredatesFeature`, `Nul` |
| `Filter::rows(format, body, settings)` | `chs_filter_rows` | same contract as `Schema::rows` | `FilterResult` — per-row `Verdict`s; `Error`/`Decline` are NOT answers, fail closed | `PredatesFeature`, `BadDocument`, `Nul` |
| `Schema::parse_block(format, body, settings)` | `chs_block_parse` | parse a body ONCE; per-row parse failures live IN the block | `Block<'_>` (borrows the schema), evaluable by K filters with no re-parse | `Schema` (115 unknown setting, framing, decode fault — no block, no partial answers), `Unsupported`, `PredatesFeature`, `Nul` |
| `Filter::eval(&block)` | `chs_filter_eval` | an already-parsed `Block` | the same `FilterResult` `rows` answers — `eval(parse_block(body))``rows(body)`; a cross-schema pair answers `Rejected`/1002 | `CrossLibrary` (a pair from two libraries), `BadDocument`, `PredatesFeature` |
| `Filter` / `Block` drop | `chs_filter_free` / `chs_block_free` || — (the borrow makes drop-before-schema a compile-time fact) ||
| `Schema` drop | `chs_schema_free` ||||
| `parse_version_result` / `parse_changed_settings_result` / `parse_columns_result` | derived (parsers) | JSONEachRow bytes from the three queries | version / `Vec<(name, value)>` / `Vec<DiscoveredColumn>` | `Discovery` |
| `reconstruct_ddl(&cols)` | derived | discovered columns | column-declaration list for `compile` | `Discovery` |
| `RowResult` / `BatchResult` / `Value` / `Transform` (+ `lossy()`) / `Substitution` / `Computed` / `Outcome` | derived from the result document || see rustdoc; `BatchResult::engine_rows_bytes()` is the byte-exact stored truth ||
| `RawText` ||| byte-exact stored rendering; `as_str()` is fallible, `to_lossy()` explicit ||
| Constants: `Format` (codes 0–9), `CompileMode::Declared`=0, `CODE_UNSUPPORTED`=−2, `ABI_REVISION` (4), `DEFAULT_TIMEZONE`, `NO_SETTINGS`, `NO_PARAMS`, `REGISTRY_ENV`, `AUTOFETCH_ENV`, `SYSTEM_ARTIFACT_ROOTS`, `CODE_ARTIFACT_*` / `CODE_SOURCE_UNREACHABLE`, `FETCH_COMMAND`, `SETTING_*` (the chtypes keys), `reason::*` (24 stable spellings) |||||

## Filters, query parameters and the block twin (ABI revisions 3–4)

`Schema::compile_filter(expr, NO_PARAMS)` compiles one boolean expression
against the schema's physical columns and `Filter::rows(...)` answers
per-row verdicts with **WHERE-side** semantics (`x = 256` over `UInt8`
promotes — false for every row — it never wraps), computed by ClickHouse's
own comparison functions. Four verdicts: `Verdict::True`/`False` are
answers; `Verdict::Error` (the predicate threw — a real server fails the
whole query) and `Verdict::Decline` (this library declines) are NOT, and an
enforcing caller MUST fail closed on both. **Enforcement gate**: no
read-side security may be enforced on this surface until the WHERE-truth rig
gates green — until then it is shadow/replay only (`spec/c-abi.md`
§Filters).

**Params (revision 4).** The expression may contain `{name:Type}` query
parameters, bound positionally — `compile_filter("t = {t:String}",
&[("t", "acme")])`, the crate's slice convention (`NO_PARAMS` for none) —
values are **strings**, exactly as the server's own parameter channels carry
them. Substitution is the server's own `ReplaceQueryParameterVisitor`: each
value is deserialized by the DECLARED type's own reader and injected as a
typed literal AFTER SQL parsing, so a value is never SQL text and **must
never be hand-escaped into the expression** — injection safety is by
construction, and a hostile value (`' OR 1=1 --`) compares as exactly that
literal. An UNBOUND parameter is the server's own **456** ("Substitution
`name` is not set"), an unparseable value the server's own **457** — both
`Error::Schema`, verbatim; a bound name the expression never uses is
ignored. The compiled handle bakes the values in — identity is per
(schema, expr, params) — so **a caller compiling filters from
tenant-influenced values MUST bound its cache and its compile rate**: a
bounded LRU keyed on (schema generation, expr, params-hash) plus a
per-principal compile throttle; an unbounded cache is a memory DoS and an
unmetered compile path is a CPU DoS (`spec/c-abi.md` §Filters, normative).

**Choose the brace type for the value's domain.** A bound value is
deserialized by the brace type's OWN reader (`deserializeTextEscaped`),
which **wraps** an out-of-domain integer — `{p:UInt8}` given `"256"` binds
`0` and matches every genuine zero (measured, uniform 24.8–26.7,
server-matched) — while the same constant written as a literal in the
expression PROMOTES (`x = 256` over `UInt8` is simply never true). A
too-narrow parameter type therefore silently matches the wrong rows: size
the type for the tenant-supplied domain (`{p:UInt64}`, `{p:String}`) or
validate the value before binding it. Malformed integer spellings refuse
loudly with the server's own **457** (`"-1"`, `"+7"`, `"007"` as `UInt8`);
an empty string refuses with **32**. If one name is bound twice, the LAST
binding wins — the server's own `insert_or_assign` rule (reachable here:
`params` is a slice of pairs, and the last pair with a name wins).

One naming trap, the transport's rather than this library's: on a real
server's **TCP** channel a parameter *named* `limit` or `offset` fails at
the protocol layer with code 26 even when unused (params ride in a Settings
block there); HTTP is fine, and this library matches the HTTP/substitution
semantics. Avoid those two names for anything that will ever cross TCP.

**The block twin (revision 4).** `Schema::parse_block(format, body,
settings)` parses a body ONCE into a `Block<'_>` — the parse half of
`Filter::rows` — and `Filter::eval(&block)` answers the same `FilterResult`
with no re-parse: the live-SSE hot path is K filters × 1 event, and the
re-parse is shed. Normative equivalence: `eval(&parse_block(body)?)` ≡
`rows(body)` for every verdict class (volatile DEFAULTs resolve against the
PARSE call's clock instant — pin `chtypes_now_epoch_nanos` for cross-call
identity). Evaluation is a pure function of (filter, block), takes no
settings, and never consumes or mutates the block. Filter and block MUST
come from the SAME schema: a mismatched same-library pair answers
`FilterOutcome::Rejected`/1002 loudly, and a cross-library pair is
`Error::CrossLibrary` before any C call. A `Block` borrows its `Schema`
exactly as a `Filter` does — the wrong free order does not compile.
Parse-once does not open enforcement earlier: the twin is under the same
enforcement gate.

## Settings and precedence

The row path resolves **three settings channels in one order** — later wins:

```
per-call map  >  handle compile profile  >  library defaults (set_default_settings)  >  ClickHouse defaults
```

One documented exception: a **type gate named in the compile profile** binds
at compile — where a real server binds it, at CREATE — and then outranks the
per-call map for that handle's life (a profile declaring it at a refusing
value fails the compile with the server's own 455/44). Gates the profile does
not name keep the per-row re-check.

Rules that are enforced, not advisory:

- **Values are strings at the boundary.** The crate's `&[(k, v)]` shape makes
  this structural: `chtypes_now_epoch_nanos` is a 19-digit nanosecond epoch,
  and a caller that routes it through a float sends `1.7e+18` — the setting is
  silently ignored. Never stringify through a float.
- **An unknown setting name rejects the whole call with the server's own
  code 115** (did-you-mean hint included), on every channel;
  `set_default_settings` refuses its payload **wholesale**, nothing committed.
- The six `chtypes_*` keys are the entire reserved namespace — three per-call
  (`SETTING_NOW_EPOCH_NANOS`, `SETTING_CLOCK_OFFSET_NANOS`,
  `SETTING_MAX_CLOCK_SKEW_NANOS`) and three per-process
  (`SETTING_DEFAULT_EVAL_MEMORY_BYTES`, `SETTING_DEFAULT_EVAL_WALL_NANOS`,
  plus `chtypes_custom_settings_prefixes`). A per-process key handed to
  `rows()` comes back on `unsupported_settings` — a decline, and the row is
  promoted to `Outcome::Unsupported`, never scored as agreement.
- `NO_SETTINGS` is the spelled-out empty map for the common call.

## Discovery

This crate **never connects to ClickHouse** — the app runs three canonical
queries at connect time with whatever client it already has, and declares the
answers back:

| Constant | Feeds |
|---|---|
| `QUERY_SERVER_VERSION` | `Registry::for_version` |
| `QUERY_CHANGED_SETTINGS` | the compile profile AND per-call settings |
| `QUERY_TABLE_COLUMNS` | `parse_columns_result``reconstruct_ddl``compile` |

```rust
// once per connection/tenant; cache the profile per deployment
let version  = chtypes::parse_version_result(&query(chtypes::QUERY_SERVER_VERSION))?;
let settings = chtypes::parse_changed_settings_result(&query(chtypes::QUERY_CHANGED_SETTINGS))?;
let lib = registry.for_version(&version)?;

let cols = chtypes::parse_columns_result(&query(chtypes::QUERY_TABLE_COLUMNS))?;
let schema = lib.compile(&chtypes::reconstruct_ddl(&cols)?)
    .settings(settings.clone())
    .compile()?;                       // a typo'd setting = the server's own 115, here
let batch = schema.rows(Format::JsonEachRow, body, &settings)?;   // same profile per call
```

Never ask the customer for their settings — ask their server. The parsers keep
integer fields exact through both quoted and bare spellings (never through a
float), and `reconstruct_ddl` carries `default_kind`/`default_expression`,
without which DEFAULT/MATERIALIZED/EPHEMERAL semantics are silently lost.
`system.columns` reports the table **as stored**, so reconstruction is
shape-faithful exactly when the discovered profile is declared at compile.

## Multi-version use and thread-safety

- Every artifact loads with `RTLD_NOW | RTLD_LOCAL` — that is the whole
  mechanism letting several ClickHouse builds coexist in one process. Cost,
  measured: ~120 MB resident per loaded version.
- `for_version` resolves a minor line (`"25.8"`) or an exact patch
  (`"25.8.28.1-lts"`); a drifted patch resolves to its minor line. Failure
  names what is loaded — there is deliberately no nearest-version fallback,
  because version behaviour is not monotonic (25.10 rejects a DEFAULT that
  25.8 and 26.6 both accept).
- **One mutex per loaded image** serialises every call into a `Library`  including `set_default_settings`, which the ABI requires be excluded against
  everything else on the same image. `Library` and `Registry` are
  `Send + Sync`; share them freely.
- `Schema` is `Send` and deliberately **not** `Sync`: one native handle must
  not be used from two threads at once, and the type system enforces it.
  Parallelism comes from more schemas, not shared ones.
- `Registry::shutdown()` / `Library::shutdown()` join the DEFAULT evaluator's
  threads. `chs_init` registers the same via `atexit`, so an ordinary process
  needs no call; it is **required before `dlclose`** — which this crate never
  performs itself (the handle is intentionally leaked to the process).

## Known limitations

- **The error model is normative**`spec/c-abi.md` §Error model. A decline
  (`Error::Unsupported` / `Outcome::Unsupported`, code −2) is neither an
  acceptance nor a rejection; both over-accepts and over-rejects are budgeted
  at zero.
- **EPHEMERAL columns:** an INSERT whose explicit column list names an
  EPHEMERAL column cannot be previewed by `row`/`rows` (a format stream
  carries no column list). Detect via `Schema::columns()`'s
  `DefaultKind::Ephemeral` and decline that intersection —
  `spec/bindings.md` §EPHEMERAL.
- **macOS is a dev floor, not an oracle:** its `long double` is 53-bit, so
  float parses diverge from a real server (Linux matches 395/395 of the float
  corpus; macOS 0/395). Float expectations must come from a Linux artifact or
  a live server.
- **Declined by design:** unmodelled engines and sorting keys,
  `WHERE`/`GROUP BY` TTLs, `TO DISK`/`VOLUME` moves, `RECOMPRESS`,
  clock-reading TTL expressions, server/session-property DEFAULTs
  (`hostName()`, `currentUser()`), blocking DEFAULTs (`sleep`), DEFAULTs past
  the admission budgets, MergeTree settings declared at non-default values.
  Each is `Error::Unsupported` — fall back to the server.
- `Format::Native`/`Buffers`/`RowBinary*` support depends on the loaded
  artifact's age, not this crate's version — probe the artifact
  (`spec/bindings.md` §Values a binding must accept and reject).

## More

- `cargo run --example demo` — the product in one screen (overflow, pinned
  `now()`, TTL, reject, decline).
- [`playground/rust/`]../../playground/rust/ — a nine-section runnable tour;
  the go/, python/ and ts/ tours beside it print the same nine sections.
- `cargo test` — integration tests and the golden set skip without a registry;
  `CHTYPES_REGISTRY=/path/to/registry cargo test` runs them. The fetch suite
  (`tests/fetch.rs`) needs only `spec/fixtures/fetch/` and runs offline.
- `cargo build --no-default-features` — the loader without the `fetch`
  feature: no binary, no `ensure`, and none of the fetch dependencies
  (`ed25519-dalek`, `sha2`, `ureq`, `base64`, `tar`, `flate2`).