Files
0pt/docs/analytics/BI_ANALYTICS_CORE.md
T
Claude Opus 5 84bb9faa4f
CI (SIAX Cloud) / security (push) Successful in 41s
Security Scan (SIAX Cloud) / security (push) Successful in 40s
CI (SIAX Cloud) / integration (push) Successful in 52s
CI (SIAX Cloud) / quality (push) Successful in 1m5s
CI (SIAX Cloud) / security (pull_request) Successful in 31s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 29s
CI (SIAX Cloud) / integration (pull_request) Successful in 45s
CI (SIAX Cloud) / quality (pull_request) Successful in 53s
feat(analytics): BI-P002..BI-P012 analytics core — domain model, tiers, contracts, Arrow, ClickHouse engine, isolation, governance
Implements the eight BI program prompts that make 0pt the canonical SIAX
analytics capability. Every file is NEW: not one existing file is modified,
and src/analytics/clickhouse-analytics.ts is byte-identical to main.

BI-P002 — the nineteen canonical entities, verbatim, with tenant_id +
project_id on every tenant-owned record. sql/008 gives each one a table.
The four version entities BI-P002 names as immutable are enforced by BEFORE
UPDATE/DELETE triggers, not by a TypeScript `readonly` — proven by direct SQL
against a live Postgres.

BI-P003 — RAW/NORMALIZED/CURATED/SERVING with the read rule enforced:
assertReadableByBiSurface refuses a RAW dataset to a dashboard unless a
recorded exception exists. All eight required declarations are NOT NULL.

BI-P004 — DatasetContract and a fail-closed compatibility gate. Fifteen
breaking-change classes are each tested twice: refused under a minor bump,
accepted under a major one.

BI-P005 — Arrow IPC alongside JSON, with a hand-written FlatBuffers writer so
no npm dependency (or CVE surface) is added. Conformance comes from three
independent implementations, not from self-agreement: pyarrow 25.0.1 and
DuckDB 1.5.5 read identical values from both ClickHouse's Arrow and ours, and
ClickHouse's own Arrow C++ ingests our 20,000-row batch with exact aggregates.

BI-P009 — SiaxAnalyticsEngine with all eight capabilities. The existing
ClickHouseAnalytics is COMPOSED, never replaced. executeQuery (analyst SQL) is
declared and deliberately refused with CAPABILITY_UNAVAILABLE until BI-P017 and
BI-P033 land, rather than opening the raw-SQL hole BI-P011 enumerates.

BI-P010 — four purpose-built tables, not one giant one, and the difference is
measured against the counterfactual: 48.8ms/40,960 rows vs 174.4ms/5,400,000
rows for the same question; 5/660 granules vs 660/1149.

BI-P011 — layered isolation, each layer proven with the layer above removed.
Without the forced predicate the same statement returns 50,050 (both tenants);
with it, t_alpha gets 50. Without a row policy an unrestricted user sees 2
tenants; the restricted user running the IDENTICAL statement sees 1.

BI-P012 — every named limit, fired individually and identified by ClickHouse
error code, plus the half usually skipped: an unrelated tenant is timed while
a real CPU bomb is in flight and must still return the CORRECT answer.

Three defects found by writing tests that can fail:
  * `CHECK (array_length(purposes,1) >= 1)` can NEVER fail — array_length is
    NULL for '{}' and a NULL CHECK passes. Fixed with coalesce(); the test
    that caught it is pinned.
  * EXPLAIN ESTIMATE run under the caller's own max_rows_to_read fails with
    code 158 exactly when the estimate would be over budget. Planner calls now
    use a narrower envelope.
  * result_overflow_mode=break returns HTTP 200 with 65,409 of 100,000 rows
    and no indication it is short. Every SELECT ships `throw`.

Verified rather than assumed: server7's ClickHouse holds `opt_scraped` (51
tables, 407,793,471 rows, `persons`/`cz_owners` included) — the b0ts plane this
module is prohibited from touching — has no `opt_analytics` database, zero
tenant_id columns and zero row policies. Nothing there was modified; every
proof ran against disposable containers.

Evidence: evidence/bi-p002-p012/{test-transcript.txt,bi-p010-benchmark.json,
bi-p005-arrow-roundtrip.json,server7-clickhouse-probe.md}
Docs: docs/analytics/BI_ANALYTICS_CORE.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:01:27 +02:00

272 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 0pt Analytics Core — BI-P002 … BI-P012
Status of this document: it describes what **is in this repository and has been
run**, and states separately what has **not** been deployed. Nothing here is a
plan written in the present tense.
---
## 0. The ownership boundary (normative, not negotiable)
```
SIAX / 0pt owns the analytics engine, the query compiler, the tenant forcing,
the resource governance and the semantic/contract layer.
Product repos (VehIQ and the rest) own DOMAIN MEANING — what a row means,
which metric a dealer cares about, how a screen reads.
```
Concretely, and enforced rather than asserted:
* `BIApplicationBinding` (BI-P002 entity 19) has **no credential field**, in
TypeScript or in `sql/008_analytics_domain_model.sql`. A bound BI
application authenticates to the 0pt Analytics API with a CL0UD identity and
receives compiled, tenant-forced results. It never learns a ClickHouse DSN,
user, or password. Two tests assert the absence of such a column — one over
the migration text, one over `information_schema.columns` on a live
Postgres — so adding one later is a test failure.
* `AnalyticsIdentity` (BI-P009) is a **separate parameter** from the request,
never a field on it. This estate has already shipped a live cross-tenant
hole by reading a tenant out of a request body (INF0 Wave 2); the type
shape is what stops that recurring here.
---
## 1. What each prompt produced
| Prompt | Deliverable | Where |
|---|---|---|
| BI-P002 | 19 canonical entities + migrations + tenant-scoped persistence + restore drill | `src/analytics/domain/entities.ts`, `sql/008_analytics_domain_model.sql`, `src/analytics/repositories/analytics-domain-repository.ts` |
| BI-P003 | RAW / NORMALIZED / CURATED / SERVING, with the BI-surface read rule enforced | `src/analytics/domain/tiers.ts` |
| BI-P004 | `DatasetContract` + a fail-closed compatibility gate | `src/analytics/domain/contract.ts` |
| BI-P005 | Arrow IPC encode/decode + ClickHouse pass-through | `src/analytics/arrow/ipc.ts`, `src/analytics/arrow/flatbuffer-builder.ts` |
| BI-P009 | `SiaxAnalyticsEngine` interface + ClickHouse provider, existing implementation **preserved** | `src/analytics/engine/siax-analytics-engine.ts`, `src/analytics/engine/clickhouse-engine.ts` |
| BI-P010 | Four purpose-built ClickHouse tables + rollup MV, benchmarked | `src/analytics/engine/schema.ts`, `scripts/bi-p010-benchmark.mjs` |
| BI-P011 | Layered tenant isolation: verified identity, forced compiler predicate, restricted users, row policies | `src/analytics/engine/query-compiler.ts` |
| BI-P012 | Every named ClickHouse limit + CL0UD quota + per-tenant admission control | `src/analytics/engine/resource-governance.ts` |
---
## 2. BI-P009 — what "preserve" means here
`src/analytics/clickhouse-analytics.ts` is **byte-identical to main**. The new
engine composes it:
* `health()` delegates to it;
* the pre-existing `opt_metric_events` path is reachable through
`executeLegacyMetric()`, still using its own compiler;
* everything else uses the new compiler for the BI-P010 tables.
"Do not regress the existing forced tenant predicate" is enforced by
`tests/analytics/tenant-predicate-regression.test.ts`, which asserts the exact
string `tenant_id = {tenant:String}` on the URL the adapter builds. It has been
run with the forcing deliberately deleted from both compilers and goes RED
(2 of 6 tests fail); the transcript is
`evidence/bi-p002-p012/test-transcript.txt` §1–§2.
The eight capabilities are all implemented, with one **deliberate refusal**:
`executeQuery` (analyst SQL) throws `CAPABILITY_UNAVAILABLE` until BI-P017
(SQL dialect contract) and BI-P033 (analytics authorization) exist. Shipping a
raw-SQL surface first would create exactly the "raw SQL attempt" hole BI-P011
enumerates. It is declared and refused, not silently missing.
---
## 3. BI-P010 — why four tables, measured
BI-P010 warns against "one giant unbounded table for every analytics use
case". `scripts/bi-p010-benchmark.mjs` builds both fixtures and the
counterfactual, and times them. Measured on a disposable
`clickhouse/clickhouse-server:24.8` (full transcript:
`evidence/bi-p002-p012/bi-p010-benchmark.json`):
| Question | Purpose-built | One giant table |
|---|---|---|
| 90-day metric total, one tenant | **48.8 ms p50**, 40 960 rows read | 174.4 ms p50, **5 400 000** rows read |
| Vehicle point lookup by key, one tenant | **49.3 ms p50**, 8 192 rows read | 236.6 ms p50, **7 401 152** rows read |
| Avg asking price by make over 2M vehicles | **389.6 ms p50** | 680.8 ms p50 |
| Granules read for the metric total | **5 / 660** | 660 / 1 149 |
Fixtures: 5 400 000 SaaS events (200 tenants × 12 metrics × 90 days) and
4 000 000 vehicle projections (2M for the tenant under test + 2M decoys owned
by 50 other tenants).
**Honest finding that does not flatter the design:** at this scale the
`AggregatingMergeTree` rollup (37.98 ms p50, 21 416 rows read) was *not* faster
in wall time than scanning raw events (32.55 ms p50, 40 960 rows read). It
reads about half the rows, and its value is the long TTL — raw events expire
in 30 days, the rollup keeps three years — so the rollup earns its keep on
retention and on scale beyond this fixture, not on latency at 5.4M rows. Do
not quote it as a speed win.
---
## 4. BI-P011 — the isolation proof
Four layers, and each one is tested with the layer above it *removed*:
1. **Verified CL0UD identity.** `AnalyticsIdentity` is not part of the
request; a blank tenant or principal is `UNAUTHENTICATED`, never a wildcard.
2. **The 0pt query compiler forces the tenant.** Every compiled statement
contains `tenant_id = {tenant:String}` as its first WHERE condition, with
the value bound as an HTTP parameter — the tenant string never appears in
the SQL text.
3. **Restricted database users + row policies.**
`restrictedTenantUserDdl()` renders a per-tenant ClickHouse user with
`readonly = 1` and a `CREATE ROW POLICY ... USING tenant_id = '<tenant>'`
on every analytics table.
4. **Separate database/cluster tier** for very-high-isolation customers:
supported by construction — the engine takes a `database` and a `baseUrl`
per instance — but **not yet operated**, because there is no deployed
ClickHouse for 0pt analytics at all (see §7).
The proof, run against a disposable ClickHouse with 50 rows for `t_alpha`
(value 1 each) and 50 for `t_beta` (value 1000 each):
* **hole, layer 2 removed:** the same statement minus the forced predicate
returns **50 050** — both tenants in one number;
* **closed, layer 2 present:** `t_alpha` gets **50**, `t_beta` gets **50 000**;
* **hole, layer 3 absent:** an unrestricted user issuing a predicate-free
`uniqExact(tenant_id)` sees **2** tenants;
* **closed, layer 3 present:** the restricted user issuing the *identical*
statement sees **1**, and `sum(value)` returns 50 rather than 50 050.
Adversarial cases from BI-P011's list — tenant A querying B's dataset, forged
dataset IDs, raw SQL, SQL injection, dimension identifier injection, filter
injection — are covered in
`tests/analytics/query-compiler-injection.test.ts` (compiler layer) and
`tests/integration/analytics-tenant-isolation.integration.test.ts` (live).
Dimension names are **resolved through a per-tenant allow-list**
(`DimensionDefinition`), never escaped and never spliced. A tenant that has
declared no dimensions may reference none — fail closed.
---
## 5. BI-P012 — governance, and the partial-result trap
Every setting BI-P012 names is applied. Five ride on the statement itself
(`max_execution_time`, `max_result_rows`, `max_rows_to_read`,
`max_bytes_to_read`, `max_memory_usage`) under `readonly = 1`, so the query
text cannot re-raise them; `max_concurrent_queries` is a per-user quota plus a
local admission gate.
Overflow modes are **`throw`, never `break`**. That is not a style preference.
Measured on ClickHouse 24.8: with `result_overflow_mode = break` and
`max_result_rows = 10`, a 100 000-row query returns **HTTP 200 with 65 409
rows** and nothing in the payload says it is short. A truncated answer that
looks complete is the analytics form of a false green.
Query-bomb outcomes, each fired individually and identified by ClickHouse
error code: 158 `TOO_MANY_ROWS`, 307 `TOO_MANY_BYTES`, 159 `TIMEOUT_EXCEEDED`,
396 `TOO_MANY_ROWS_OR_BYTES`, 241 `MEMORY_LIMIT_EXCEEDED`.
The second half of the DoD — "without degrading unrelated tenant beyond
declared SLO" — is timed, not assumed: an unrelated tenant's interactive query
is run five times *while* an 8-second CPU bomb is in flight, must still return
the **correct** answer, and must stay under a declared 5000 ms ceiling (measured ~30 ms on an idle
server; the ceiling is set wide for a shared CI runner, and a genuinely
starved tenant would take seconds or fail).
Two measured details worth keeping:
* `EXPLAIN ESTIMATE` must **not** run under the caller's own
`max_rows_to_read`. It fails with code 158 exactly when the estimate would
be over budget — i.e. in the only case the estimate matters. Planner calls
use `planningSettings()`, a narrower envelope, instead.
* `SELECT count() FROM numbers(2e10)` is *not* a query bomb — ClickHouse
answers it in about 5 seconds because it can skip the values. The bomb in
the test hashes every row, which cannot be short-circuited.
---
## 6. BI-P005 — Arrow, and why it is not self-certified
Two paths:
* **pass-through** — ClickHouse emits Arrow IPC itself (`FORMAT ArrowStream`);
0pt streams those bytes unchanged. `output_format_arrow_string_as_string=1`
is required, or String columns arrive as Arrow **Binary** and both pyarrow
and DuckDB read them as bytes: silent semantic loss.
* **encoded** — for results 0pt assembles itself, `encodeArrowStream()` writes
the IPC stream, including a small hand-written FlatBuffers writer (no new
npm dependency, no new supply-chain surface).
A hand-rolled encoder is only worth having if somebody else's parser agrees
with it, so conformance is established by three independent implementations:
* **pyarrow 25.0.1** and **DuckDB 1.5.5** decode both ClickHouse's bytes and
0pt's bytes to *identical* values and types
(`evidence/bi-p002-p012/bi-p005-arrow-roundtrip.json`, verdict `PASS`);
* **ClickHouse's own Arrow C++** ingests 0pt-encoded Arrow — including a
20 000-row batch whose aggregates come back exact — in
`tests/integration/analytics-arrow-clickhouse.integration.test.ts`, which
runs on every push.
Supported types and their caveats are the table in `ARROW_TYPE_SUPPORT`;
`DateTime64(6|9)` and `UInt64` above 2^63 are deliberately **outside** the
supported set rather than silently lossy.
Arrow is also not an escape hatch from BI-P012:
`assertArrowWithinResultBudget()` decodes the row count and refuses a payload
over the caller's `max_result_rows`.
---
## 7. What is NOT done — verified, not assumed
* **There is no deployed ClickHouse for 0pt analytics.** Probed 2026-09-06 on
server7, the only ClickHouse in the estate: databases are
`opt_scraped`, `default`, `system`, `INFORMATION_SCHEMA`,
`information_schema`. There is no `opt_analytics` database, so
`ClickHouseAnalytics` — the "existing working" provider — has never been
run against a production instance. Everything in this document was proven
against disposable containers.
* **server7's ClickHouse must not be used for this.** It holds
`opt_scraped` — 51 tables, 407 793 471 rows, including `persons` and
`cz_owners`. That is the b0ts scraping plane, which
`src/analytics/clickhouse-analytics.ts`'s own header already prohibits this
module from touching (Ratsit/person-PII legal case, written DPIA
prohibition on quarantined market data). It also has **zero** columns named
`tenant_id` or `project_id` anywhere on the server and **zero** row
policies, so it could not satisfy BI-P011 even if it were legally
available. Nothing on that server was modified by this work.
* **BI-P011 layer 5** (separate database/cluster tier per high-isolation
customer) is supported by construction but not operated.
* **`executeQuery`** is refused pending BI-P017/BI-P033 (§2).
* The domain repository covers `AnalyticsDataset`, `DatasetVersion`,
`MetricDefinition`, `MetricVersion` and `DimensionDefinition`. The other
fourteen entities have tables, constraints and types but no repository
methods yet; their consumers (dashboards, reports, quality runs, lineage)
arrive with BI-P025 … BI-P035.
---
## 8. Running the proofs yourself
```bash
# unit suites (no infrastructure)
pnpm vitest run tests/analytics
# live ClickHouse suites
docker run -d --name opt-bi-ch -p 18123:8123 \
-e CLICKHOUSE_USER=opt_admin -e CLICKHOUSE_PASSWORD=<throwaway> \
-e CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT=1 clickhouse/clickhouse-server:24.8
CLICKHOUSE_URL=http://localhost:18123 CLICKHOUSE_AUTH=opt_admin:<throwaway> \
pnpm vitest run tests/integration
# BI-P002 restore drill
docker run -d --name opt-bi-pg -p 15455:5432 \
-e POSTGRES_USER=opt -e POSTGRES_PASSWORD=<throwaway> -e POSTGRES_DB=opt_bi_test \
postgres:16-alpine
ANALYTICS_TEST_DATABASE_URL=postgres://opt:<throwaway>@localhost:15455/opt_bi_test \
pnpm vitest run tests/analytics/analytics-domain-postgres.realinfra.test.ts
# BI-P010 benchmark and BI-P005 external roundtrip (rewrite the evidence files)
CLICKHOUSE_URL=... CLICKHOUSE_AUTH=... node scripts/bi-p010-benchmark.mjs
CLICKHOUSE_URL=... CLICKHOUSE_AUTH=... node scripts/bi-p005-arrow-roundtrip.mjs
```
Both scripts CREATE and DROP a database. Never point either at server7 or at
any ClickHouse holding b0ts or quarantined market data.