feat(analytics): BI-P002..BI-P012 — 0pt analytics core (domain model, tiers, contracts, Arrow, ClickHouse engine, tenant isolation, resource governance) #34

Merged
admin merged 1 commits from feat/bi-p002-p012-analytics-core into main 2026-09-06 21:04:04 +00:00
Owner

What this is

BI-P002, BI-P003, BI-P004, BI-P005, BI-P009, BI-P010, BI-P011 and BI-P012 from
SIAX Analytics & BI — Final Production Program Part I, all in 0pt, the
declared owner of every one of them.

Every file is new. Not one existing file is modified. git diff main -- src/analytics/clickhouse-analytics.ts is 0 lines: BI-P009 says preserve the
existing working ClickHouseAnalytics
, and it is preserved literally, then
composed rather than replaced. .gitea/workflows/** is untouched.

Prompt Deliverable
BI-P002 19 canonical entities (verbatim names) + sql/008 + tenant-scoped repository + restore drill
BI-P003 RAW/NORMALIZED/CURATED/SERVING, with the "no dashboard reads raw" rule enforced
BI-P004 DatasetContract + fail-closed compatibility gate
BI-P005 Arrow IPC encode/decode + ClickHouse pass-through, no new npm dependency
BI-P009 SiaxAnalyticsEngine (all 8 capabilities) + ClickHouse provider
BI-P010 Four purpose-built ClickHouse tables + rollup MV, benchmarked against the counterfactual
BI-P011 Four-layer tenant isolation
BI-P012 Every named limit + CL0UD quota + per-tenant admission control

Verification — what it looks like when the answer is NO

This estate has produced many false greens, so each claim below was made
falsifiable before it was made.

The BI-P009 non-regression line. tests/analytics/tenant-predicate-regression.test.ts
asserts the literal string tenant_id = {tenant:String} on the URL the adapter
builds. I deleted the forcing from both compilers and re-ran it: 2 of 6
tests go RED
, then green again once restored. Transcript §1–§2.

BI-P011, hole shown open before it is shown closed. Against a disposable
ClickHouse seeded with 50 rows for t_alpha (value 1) and 50 for t_beta
(value 1000):

without the fix with the fix
layer 2 (compiler forcing) the same statement minus the predicate returns 50 050 t_alpha gets 50, t_beta gets 50 000
layer 3 (row policy) unrestricted user, predicate-free uniqExact(tenant_id) → 2 tenants restricted user, identical statement → 1; sum(value) = 50, not 50 050

Layer 5 (separate cluster tier) is supported by construction but not
operated
— said plainly in the docs rather than ticked off.

BI-P010, measured not asserted. 5.4M SaaS events + 4M vehicle projections
(2M under test, 2M decoys across 50 other tenants), plus the "one giant table"
the prompt warns against:

Question Purpose-built One giant table
90-day metric total, one tenant 48.8 ms, 40 960 rows read 174.4 ms, 5 400 000 rows read
Vehicle point lookup by key 49.3 ms, 8 192 rows read 236.6 ms, 7 401 152 rows read
Granules read 5 / 660 660 / 1 149

Finding that does not flatter the design, stated in the docs: at this scale
the AggregatingMergeTree rollup (37.98 ms) was not faster in wall time
than scanning raw events (32.55 ms). It reads half the rows; its real value is
the long TTL. Do not quote it as a speed win.

BI-P005, not self-certified. A hand-rolled Arrow encoder is worthless
unless somebody else's parser agrees, so three independent implementations
were used: pyarrow 25.0.1 and DuckDB 1.5.5 decode both ClickHouse's
Arrow and ours to identical values and types (verdict PASS), and
ClickHouse's own Arrow C++ ingests a 20 000-row 0pt-encoded batch and
returns exact aggregates — that last one runs in CI on every push.

BI-P012, both halves. Each named limit is fired individually and
identified by ClickHouse code (158/307/159/396/241). The half usually skipped
— "without degrading unrelated tenant" — is timed: an unrelated tenant's query
runs five times while a real CPU bomb burns, and must still return the
correct answer.

Three defects the tests caught

  1. A constraint that could never fail. CHECK (array_length(allowed_purposes, 1) >= 1)
    evaluates to NULL for '{}', and a NULL CHECK passes in Postgres — so
    the GDPR purpose-limitation guard admitted purposeless grants. Fixed with
    coalesce(...); the test that found it is pinned as a regression.
  2. An estimator that refuses to estimate. EXPLAIN ESTIMATE run under the
    caller's own max_rows_to_read fails with code 158 exactly when the
    estimate is over budget — the only case that matters. Planner calls now use
    a narrower envelope (planningSettings).
  3. A partial result that looks complete. result_overflow_mode=break with
    max_result_rows=10 returns HTTP 200 with 65 409 of 100 000 rows and
    nothing says it is short. Every SELECT ships throw.

The architecture boundary, enforced not asserted

BIApplicationBinding has no credential column — in TypeScript or in SQL.
Two tests assert its absence (migration text, and information_schema.columns
on a live Postgres), so adding one later is a test failure. A bound BI
application authenticates with a CL0UD identity and receives compiled,
tenant-forced results; it never learns a DSN. AnalyticsIdentity 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.

⚠️ Verified, and it changes what can be claimed

Probed live on server7 (read-only, metadata only, nothing written):

  • There is no opt_analytics database anywhere in the estate. The
    "existing working" ClickHouse provider has never run against a deployed
    instance
    . It works — proven here against real ClickHouse — but it is not
    in production.
  • server7's ClickHouse is the b0ts plane and is off limits to this module.
    It holds opt_scraped: 51 tables, 407 793 471 rows including persons
    and cz_owners. clickhouse-analytics.ts's own header already forbids
    pointing this module at it (Ratsit/person-PII legal case, written DPIA
    prohibition). The briefing note about "40M+ Swedish vehicle rows on server7"
    is accurate about the server and describes the plane 0pt analytics may not
    use.
  • It could not satisfy BI-P011 anyway: zero columns named tenant_id or
    project_id on the whole server, and zero row policies.

A separate ClickHouse (or at minimum its own database with restricted users)
is needed before any of this reaches production. Details:
evidence/bi-p002-p012/server7-clickhouse-probe.md.

Gates

typecheck (blocking)  EXIT=0
test      (blocking)  463 passed | 80 skipped (543)   EXIT=0
build     (blocking)  EXIT=0
lint      (advisory)  12 pre-existing errors in tests/p0/* — ZERO in any file this branch adds

The five tests/integration/analytics-* suites run in CI's integration job
against the live ClickHouse service container, which already hard-fails on a
skipped suite.

One honest gap: tests/analytics/analytics-domain-postgres.realinfra.test.ts
(15 tests: restore drill, immutability triggers, tenant scoping) cannot run
in CI
— there is no Postgres service container in the quality job, and
adding one would mean editing .gitea/workflows/**, a protected file pattern
that would block this PR from merging. It was run for real against a
disposable postgres:16-alpine; the transcript is committed at
evidence/bi-p002-p012/test-transcript.txt §7. Wiring it into CI is a
separate, workflow-touching change.

Not done, and said so

  • executeQuery (analyst SQL) is declared and refused with
    CAPABILITY_UNAVAILABLE until BI-P017 (dialect contract) and BI-P033
    (authorization) exist.
  • Fourteen of the nineteen entities have tables, constraints and types but no
    repository methods yet; their consumers arrive with BI-P025…BI-P035.
  • BI-P011 layer 5 (per-customer cluster tier) is supported, not operated.

🤖 Generated with Claude Code

## What this is BI-P002, BI-P003, BI-P004, BI-P005, BI-P009, BI-P010, BI-P011 and BI-P012 from *SIAX Analytics & BI — Final Production Program Part I*, all in `0pt`, the declared owner of every one of them. **Every file is new.** Not one existing file is modified. `git diff main -- src/analytics/clickhouse-analytics.ts` is 0 lines: BI-P009 says *preserve the existing working `ClickHouseAnalytics`*, and it is preserved literally, then composed rather than replaced. `.gitea/workflows/**` is untouched. | Prompt | Deliverable | |---|---| | BI-P002 | 19 canonical entities (verbatim names) + `sql/008` + tenant-scoped repository + restore drill | | BI-P003 | RAW/NORMALIZED/CURATED/SERVING, with the "no dashboard reads raw" rule enforced | | BI-P004 | `DatasetContract` + fail-closed compatibility gate | | BI-P005 | Arrow IPC encode/decode + ClickHouse pass-through, no new npm dependency | | BI-P009 | `SiaxAnalyticsEngine` (all 8 capabilities) + ClickHouse provider | | BI-P010 | Four purpose-built ClickHouse tables + rollup MV, benchmarked against the counterfactual | | BI-P011 | Four-layer tenant isolation | | BI-P012 | Every named limit + CL0UD quota + per-tenant admission control | ## Verification — what it looks like when the answer is NO This estate has produced many false greens, so each claim below was made falsifiable before it was made. **The BI-P009 non-regression line.** `tests/analytics/tenant-predicate-regression.test.ts` asserts the literal string `tenant_id = {tenant:String}` on the URL the adapter builds. I deleted the forcing from *both* compilers and re-ran it: **2 of 6 tests go RED**, then green again once restored. Transcript §1–§2. **BI-P011, hole shown open before it is shown closed.** Against a disposable ClickHouse seeded with 50 rows for `t_alpha` (value 1) and 50 for `t_beta` (value 1000): | | without the fix | with the fix | |---|---|---| | layer 2 (compiler forcing) | the same statement minus the predicate returns **50 050** | `t_alpha` gets **50**, `t_beta` gets **50 000** | | layer 3 (row policy) | unrestricted user, predicate-free `uniqExact(tenant_id)` → **2** tenants | restricted user, *identical* statement → **1**; `sum(value)` = 50, not 50 050 | Layer 5 (separate cluster tier) is supported by construction but **not operated** — said plainly in the docs rather than ticked off. **BI-P010, measured not asserted.** 5.4M SaaS events + 4M vehicle projections (2M under test, 2M decoys across 50 other tenants), plus the "one giant table" the prompt warns against: | Question | Purpose-built | One giant table | |---|---|---| | 90-day metric total, one tenant | **48.8 ms**, 40 960 rows read | 174.4 ms, **5 400 000** rows read | | Vehicle point lookup by key | **49.3 ms**, 8 192 rows read | 236.6 ms, **7 401 152** rows read | | Granules read | **5 / 660** | 660 / 1 149 | Finding that does *not* flatter the design, stated in the docs: at this scale the `AggregatingMergeTree` rollup (37.98 ms) was **not faster in wall time** than scanning raw events (32.55 ms). It reads half the rows; its real value is the long TTL. Do not quote it as a speed win. **BI-P005, not self-certified.** A hand-rolled Arrow encoder is worthless unless somebody else's parser agrees, so three independent implementations were used: **pyarrow 25.0.1** and **DuckDB 1.5.5** decode both ClickHouse's Arrow and ours to identical values and types (verdict `PASS`), and **ClickHouse's own Arrow C++** ingests a 20 000-row 0pt-encoded batch and returns exact aggregates — that last one runs in CI on every push. **BI-P012, both halves.** Each named limit is fired individually and identified by ClickHouse code (158/307/159/396/241). The half usually skipped — "without degrading unrelated tenant" — is timed: an unrelated tenant's query runs five times *while* a real CPU bomb burns, and must still return the **correct** answer. ## Three defects the tests caught 1. **A constraint that could never fail.** `CHECK (array_length(allowed_purposes, 1) >= 1)` evaluates to NULL for `'{}'`, and a NULL CHECK **passes** in Postgres — so the GDPR purpose-limitation guard admitted purposeless grants. Fixed with `coalesce(...)`; the test that found it is pinned as a regression. 2. **An estimator that refuses to estimate.** `EXPLAIN ESTIMATE` run under the caller's own `max_rows_to_read` fails with code 158 exactly when the estimate is over budget — the only case that matters. Planner calls now use a narrower envelope (`planningSettings`). 3. **A partial result that looks complete.** `result_overflow_mode=break` with `max_result_rows=10` returns **HTTP 200 with 65 409 of 100 000 rows** and nothing says it is short. Every SELECT ships `throw`. ## The architecture boundary, enforced not asserted `BIApplicationBinding` has **no credential column** — in TypeScript or in SQL. Two tests assert its absence (migration text, and `information_schema.columns` on a live Postgres), so adding one later is a test failure. A bound BI application authenticates with a CL0UD identity and receives compiled, tenant-forced results; it never learns a DSN. `AnalyticsIdentity` 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. ## ⚠️ Verified, and it changes what can be claimed Probed live on server7 (read-only, metadata only, nothing written): * **There is no `opt_analytics` database anywhere in the estate.** The "existing working" ClickHouse provider has **never run against a deployed instance**. It works — proven here against real ClickHouse — but it is not in production. * **server7's ClickHouse is the b0ts plane and is off limits to this module.** It holds `opt_scraped`: 51 tables, **407 793 471** rows including `persons` and `cz_owners`. `clickhouse-analytics.ts`'s own header already forbids pointing this module at it (Ratsit/person-PII legal case, written DPIA prohibition). The briefing note about "40M+ Swedish vehicle rows on server7" is accurate about the server and describes the plane 0pt analytics may not use. * It could not satisfy BI-P011 anyway: **zero** columns named `tenant_id` or `project_id` on the whole server, and **zero** row policies. A separate ClickHouse (or at minimum its own database with restricted users) is needed before any of this reaches production. Details: `evidence/bi-p002-p012/server7-clickhouse-probe.md`. ## Gates ``` typecheck (blocking) EXIT=0 test (blocking) 463 passed | 80 skipped (543) EXIT=0 build (blocking) EXIT=0 lint (advisory) 12 pre-existing errors in tests/p0/* — ZERO in any file this branch adds ``` The five `tests/integration/analytics-*` suites run in CI's `integration` job against the live ClickHouse service container, which already hard-fails on a skipped suite. **One honest gap:** `tests/analytics/analytics-domain-postgres.realinfra.test.ts` (15 tests: restore drill, immutability triggers, tenant scoping) **cannot run in CI** — there is no Postgres service container in the `quality` job, and adding one would mean editing `.gitea/workflows/**`, a protected file pattern that would block this PR from merging. It was run for real against a disposable `postgres:16-alpine`; the transcript is committed at `evidence/bi-p002-p012/test-transcript.txt` §7. Wiring it into CI is a separate, workflow-touching change. ## Not done, and said so * `executeQuery` (analyst SQL) is **declared and refused** with `CAPABILITY_UNAVAILABLE` until BI-P017 (dialect contract) and BI-P033 (authorization) exist. * Fourteen of the nineteen entities have tables, constraints and types but no repository methods yet; their consumers arrive with BI-P025…BI-P035. * BI-P011 layer 5 (per-customer cluster tier) is supported, not operated. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
admin added 1 commit 2026-09-06 21:02:19 +00:00
feat(analytics): BI-P002..BI-P012 analytics core — domain model, tiers, contracts, Arrow, ClickHouse engine, isolation, governance
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
84bb9faa4f
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>
CODEOWNERS rules requested review from sax3l 2026-09-06 21:02:19 +00:00
admin merged commit into main 2026-09-06 21:04:04 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/0pt#34