From 2bfbd3f159f741a86e8636c998d527332b28111b Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Mon, 7 Sep 2026 03:00:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20sql/009=20=E2=80=94=20execut?= =?UTF-8?q?ion=20metadata,=20metric=20governance,=20semantic=20registry,?= =?UTF-8?q?=20quarantine,=20usage=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration the previous commits' comments promised. Additive and idempotent throughout, so the restore drill can replay it onto a live database. BI-P018 adds principal_id, workload, compiled_query_hash, datasets, metrics, cache_status and cost_units to the execution record — and deliberately NO `query_text` column, with a CHECK pinning the hash to 64 hex characters so a statement smuggled into that field is refused by the database. A test asserts the absence of `query_text` and `sql` as columns. BI-P031's DoD gets a Postgres TRIGGER, not just the TypeScript guard: a certified metric whose meaning_hash changes without a version bump raises. That is the layer that holds when something writes without going through the application — a migration script, a psql session, an admin tool. A compiler opinion does not survive `psql`; a trigger does. BI-P030 gets a version-history table whose CONTENT is immutable (status and valid_from may still move) plus a PARTIAL UNIQUE INDEX enforcing at most one ACTIVE version per model per tenant. Two active versions is a silent correctness failure in which half the dashboards read a different model, so it is an index rather than application discipline. BI-P021's usage ledger is append-only by trigger: update and delete both raise. BI-P026's freshness table DEFAULTS TO 'UNKNOWN' — a dataset nobody has computed freshness for must not read as FRESH — and requires a non-empty disclosure, because a state with no words attached is a state a dashboard renders as a silent green dot. BI-P025's quarantine record requires a principal and a reason on release. A BUG IN THIS MIGRATION, FOUND BY RUNNING IT: `SELECT 1 FROM pg_constraint WHERE conname = '...'` is NOT schema-scoped. conname is unique per schema, not per database, so on a database that already had these tables in `public` every guarded CHECK was silently skipped when the file was applied into a second schema. The constraints simply were not there, and the tests that should have proven them PASSED — because the INSERTs they expected to fail succeeded, and `expect(message).toContain(...)` on an undefined message reads as a type error, not as "the constraint is missing". Every guard now scopes via `conrelid = ''::regclass`, and the fix is proven by applying the file to `public` FIRST and then running the suite into a fresh schema: 20/20. Two flaws in my own test harness, also found by running it: * `sql.unsafe("SET search_path ...; SELECT ...")` is a multi-statement simple query, and postgres.js returns the LAST statement's result — so a SELECT silently returned the connection object and two assertions compared against nonsense. search_path is now pinned on the CONNECTION. * the fixture INSERT used column names that do not exist in sql/008's dataset table, which surfaced as a suite-level failure rather than as 20 skips. 20 tests against a real Postgres 16, gated on ANALYTICS_TEST_DATABASE_URL, and each of them attempts the write the constraint exists to refuse. Co-Authored-By: Claude Opus 5 --- ...analytics_execution_metrics_governance.sql | 346 +++++++++++++++++ ...tics-governance-postgres.realinfra.test.ts | 349 ++++++++++++++++++ 2 files changed, 695 insertions(+) create mode 100644 sql/009_analytics_execution_metrics_governance.sql create mode 100644 tests/analytics/analytics-governance-postgres.realinfra.test.ts diff --git a/sql/009_analytics_execution_metrics_governance.sql b/sql/009_analytics_execution_metrics_governance.sql new file mode 100644 index 0000000..6b78200 --- /dev/null +++ b/sql/009_analytics_execution_metrics_governance.sql @@ -0,0 +1,346 @@ +-- 0pt analytics: execution metadata, metric governance, semantic registry, +-- quarantine (BI-P018, BI-P021, BI-P025, BI-P028, BI-P030, BI-P031). +-- +-- Extends sql/008. Every statement is ADDITIVE and idempotent: ALTER TABLE ... +-- ADD COLUMN IF NOT EXISTS, CREATE TABLE IF NOT EXISTS, CREATE OR REPLACE +-- FUNCTION, DROP TRIGGER IF EXISTS before CREATE TRIGGER. It can be replayed +-- onto an already-migrated database, which is what the restore drill needs. +-- +-- Ordering: requires sql/008 (it alters two of its tables). +-- +-- WHY COLUMNS RATHER THAN NEW TABLES for the metric fields: BI-P028 names +-- fifteen fields and BI-P002 already persists nine of them. A second metric +-- table would be exactly the failure BI-P028's DoD is about ("no production +-- metric exists only inside a dashboard tool" generalises to "one metric, one +-- home"), so the six missing fields are added where the metric already lives. + +-- ═══════════════════════════════════════════════════════════════════════════ +-- BI-P018 — QUERY EXECUTION MODEL +-- ═══════════════════════════════════════════════════════════════════════════ +-- The fields BI-P018 lists that sql/008's QueryExecution did not carry. +-- +-- `compiled_query_hash` and NOT the statement: BI-P018 says "without +-- unnecessarily storing sensitive raw SQL". There is deliberately no +-- `query_text` column, and the CHECK below makes adding one by accident +-- visible — a hash is 64 hex characters and nothing else. +ALTER TABLE opt_analytics_query_execution + ADD COLUMN IF NOT EXISTS principal_id TEXT, + ADD COLUMN IF NOT EXISTS workload TEXT, + ADD COLUMN IF NOT EXISTS compiled_query_hash TEXT, + ADD COLUMN IF NOT EXISTS datasets TEXT[] NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS metrics TEXT[] NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS cache_status TEXT NOT NULL DEFAULT 'miss', + ADD COLUMN IF NOT EXISTS cost_units NUMERIC(20, 6); + +-- NOTE ON THE GUARD SHAPE. `pg_constraint.conname` is NOT unique across +-- schemas: a bare `WHERE conname = '...'` matches a same-named constraint in +-- ANY schema and silently skips the ADD here. Found by running this file into +-- a second schema on a database that already had it in `public` — every CHECK +-- below was quietly absent, and the tests that should have proven them passed +-- because the INSERTs they expected to fail succeeded. Every guard therefore +-- scopes to the table via `conrelid = '
'::regclass`, which resolves +-- through search_path. +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'opt_analytics_query_execution_hash_shape' + AND conrelid = 'opt_analytics_query_execution'::regclass + ) THEN + ALTER TABLE opt_analytics_query_execution + ADD CONSTRAINT opt_analytics_query_execution_hash_shape + CHECK (compiled_query_hash IS NULL OR compiled_query_hash ~ '^[0-9a-f]{64}$'); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'opt_analytics_query_execution_cache_status' + AND conrelid = 'opt_analytics_query_execution'::regclass + ) THEN + ALTER TABLE opt_analytics_query_execution + ADD CONSTRAINT opt_analytics_query_execution_cache_status + CHECK (cache_status IN ('hit','miss','bypass','stale_refused','not_cacheable')); + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'opt_analytics_query_execution_workload' + AND conrelid = 'opt_analytics_query_execution'::regclass + ) THEN + ALTER TABLE opt_analytics_query_execution + ADD CONSTRAINT opt_analytics_query_execution_workload + CHECK (workload IS NULL OR workload IN + ('dashboard','report','api','analyst_sql','ai_analyst','scheduled','internal')); + END IF; +END $$; + +-- Cost reporting is per tenant per window; index for it rather than making +-- every billing query a sequential scan of every tenant's executions. +CREATE INDEX IF NOT EXISTS opt_analytics_query_execution_cost_idx + ON opt_analytics_query_execution (tenant_id, submitted_at DESC) + WHERE cost_units IS NOT NULL; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- BI-P017 — ANALYST SQL STATEMENT HISTORY +-- ═══════════════════════════════════════════════════════════════════════════ +-- The ONE place raw SQL is retained, and it is a separate table with a +-- separate retention posture precisely so that decision is visible. +-- +-- `statement_normalized` (literals masked) is NOT NULL: it is always present +-- and is the searchable, non-PII form. `statement_raw` is nullable, so a +-- deployment that chooses not to retain it simply leaves it NULL rather than +-- losing the history entirely. +CREATE TABLE IF NOT EXISTS opt_analytics_sql_statement ( + statement_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + project_id TEXT, + principal_id TEXT NOT NULL, + statement_normalized TEXT NOT NULL, + statement_raw TEXT, + statement_hash TEXT NOT NULL CHECK (statement_hash ~ '^[0-9a-f]{64}$'), + referenced_objects TEXT[] NOT NULL DEFAULT '{}', + outcome TEXT NOT NULL CHECK (outcome IN ('admitted','refused','succeeded','failed','cancelled')), + error_code TEXT, + refusal_reason TEXT, + submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finished_at TIMESTAMPTZ, + duration_ms INTEGER, + rows_returned BIGINT, + rows_read BIGINT, + bytes_read BIGINT, + correlation_id TEXT, + -- A refusal must say why. An unreviewable refusal log is not an audit trail, + -- and an analyst probing the boundary is exactly what an operator needs to see. + CHECK (outcome <> 'refused' OR (refusal_reason IS NOT NULL AND length(btrim(refusal_reason)) > 0)) +); +CREATE INDEX IF NOT EXISTS opt_analytics_sql_statement_tenant_idx + ON opt_analytics_sql_statement (tenant_id, submitted_at DESC); +CREATE INDEX IF NOT EXISTS opt_analytics_sql_statement_refused_idx + ON opt_analytics_sql_statement (tenant_id, submitted_at DESC) + WHERE outcome = 'refused'; + +-- ═══════════════════════════════════════════════════════════════════════════ +-- BI-P028 / BI-P031 — METRIC DOMAIN MODEL AND GOVERNANCE +-- ═══════════════════════════════════════════════════════════════════════════ +ALTER TABLE opt_analytics_metric_definition + ADD COLUMN IF NOT EXISTS metric_type TEXT NOT NULL DEFAULT 'simple', + ADD COLUMN IF NOT EXISTS time_dimension TEXT, + ADD COLUMN IF NOT EXISTS unit TEXT, + ADD COLUMN IF NOT EXISTS currency TEXT, + ADD COLUMN IF NOT EXISTS display_format TEXT NOT NULL DEFAULT 'decimal', + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'DRAFT', + ADD COLUMN IF NOT EXISTS filters_json TEXT NOT NULL DEFAULT '[]', + ADD COLUMN IF NOT EXISTS certification TEXT NOT NULL DEFAULT 'experimental', + ADD COLUMN IF NOT EXISTS business_definition TEXT, + ADD COLUMN IF NOT EXISTS technical_definition TEXT, + ADD COLUMN IF NOT EXISTS meaning_hash TEXT, + ADD COLUMN IF NOT EXISTS certified_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS certified_by TEXT; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'opt_analytics_metric_status' + AND conrelid = 'opt_analytics_metric_definition'::regclass) THEN + ALTER TABLE opt_analytics_metric_definition ADD CONSTRAINT opt_analytics_metric_status + CHECK (status IN ('DRAFT','REVIEW','ACTIVE','DEPRECATED','RETIRED')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'opt_analytics_metric_type' + AND conrelid = 'opt_analytics_metric_definition'::regclass) THEN + ALTER TABLE opt_analytics_metric_definition ADD CONSTRAINT opt_analytics_metric_type + CHECK (metric_type IN ('simple','ratio','derived','cumulative','conversion')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'opt_analytics_metric_format' + AND conrelid = 'opt_analytics_metric_definition'::regclass) THEN + ALTER TABLE opt_analytics_metric_definition ADD CONSTRAINT opt_analytics_metric_format + CHECK (display_format IN ('integer','decimal','percent','currency','duration_ms','bytes')); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'opt_analytics_metric_certification' + AND conrelid = 'opt_analytics_metric_definition'::regclass) THEN + ALTER TABLE opt_analytics_metric_definition ADD CONSTRAINT opt_analytics_metric_certification + CHECK (certification IN ('experimental','certified','deprecated')); + END IF; + -- A currency-formatted metric with no currency is how SEK gets rendered as + -- USD on a dashboard nobody double-checks. + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'opt_analytics_metric_currency_pairing' + AND conrelid = 'opt_analytics_metric_definition'::regclass) THEN + ALTER TABLE opt_analytics_metric_definition ADD CONSTRAINT opt_analytics_metric_currency_pairing + CHECK ( + (display_format = 'currency' AND currency IS NOT NULL AND currency ~ '^[A-Z]{3}$') + OR (display_format <> 'currency' AND currency IS NULL) + ); + END IF; + -- Certification is an ACT: it has an actor and a time, or it has not happened. + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'opt_analytics_metric_certification_act' + AND conrelid = 'opt_analytics_metric_definition'::regclass) THEN + ALTER TABLE opt_analytics_metric_definition ADD CONSTRAINT opt_analytics_metric_certification_act + CHECK ( + certification <> 'certified' + OR (certified_by IS NOT NULL AND certified_at IS NOT NULL + AND business_definition IS NOT NULL AND technical_definition IS NOT NULL) + ); + END IF; +END $$; + +-- ── BI-P031's DoD, in the DATABASE ──────────────────────────────────────── +-- "Certified metric cannot be modified in-place; new version required." +-- +-- The TypeScript check (assertMetricEditAllowed) is layer 1. This trigger is +-- layer 2, and it is the one that holds when something writes to the table +-- without going through the application — a migration script, a psql session, +-- an admin tool. A compiler opinion does not survive `psql`; a trigger does. +-- +-- The rule is on the MEANING HASH, so a change this trigger has never heard of +-- still counts as a change: new fields default to "changes the meaning" rather +-- than to "allowed", which is the fail-closed direction. +CREATE OR REPLACE FUNCTION opt_analytics_metric_certified_immutable() RETURNS trigger AS $$ +BEGIN + IF OLD.certification = 'certified' + AND NEW.current_version = OLD.current_version + AND NEW.meaning_hash IS DISTINCT FROM OLD.meaning_hash THEN + RAISE EXCEPTION + 'BI-P031: metric % is CERTIFIED; its definition cannot be modified in place - mint version %', + OLD.name, OLD.current_version + 1 + USING ERRCODE = 'restrict_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS opt_analytics_metric_certified_guard ON opt_analytics_metric_definition; +CREATE TRIGGER opt_analytics_metric_certified_guard + BEFORE UPDATE ON opt_analytics_metric_definition + FOR EACH ROW EXECUTE FUNCTION opt_analytics_metric_certified_immutable(); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- BI-P030 — SEMANTIC MODEL REGISTRY +-- ═══════════════════════════════════════════════════════════════════════════ +-- Persisted SEPARATELY from any implementation engine, which is the whole +-- point of the prompt. There is no column here for a provider manifest, and +-- `assertOnlySemanticModelFields` refuses one in the application; the schema +-- refuses it by simply not having anywhere to put it. +-- +-- sql/008's opt_analytics_semantic_model holds the current model; this table +-- holds the VERSION HISTORY BI-P030 enumerates, and its rows are immutable for +-- the same reason a MetricVersion's are: a dashboard pinned to version 7 must +-- mean the same thing tomorrow. +CREATE TABLE IF NOT EXISTS opt_analytics_semantic_model_version ( + semantic_model_version_id TEXT PRIMARY KEY, + model_id TEXT NOT NULL, + tenant_id TEXT NOT NULL, + project_id TEXT NOT NULL, + version INTEGER NOT NULL CHECK (version >= 1), + datasets TEXT[] NOT NULL DEFAULT '{}', + relationships_json TEXT NOT NULL DEFAULT '[]', + metrics_json TEXT NOT NULL DEFAULT '[]', + dimensions_json TEXT NOT NULL DEFAULT '[]', + valid_from TIMESTAMPTZ NOT NULL DEFAULT now(), + status TEXT NOT NULL CHECK (status IN ('draft','active','superseded','retired')), + hash TEXT NOT NULL CHECK (hash ~ '^[0-9a-f]{64}$'), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by TEXT, + UNIQUE (tenant_id, model_id, version) +); +CREATE INDEX IF NOT EXISTS opt_analytics_semantic_model_version_tenant_idx + ON opt_analytics_semantic_model_version (tenant_id, project_id, model_id, version DESC); + +-- At most ONE active version per model per tenant. A partial unique index +-- rather than application discipline: two active versions is a silent +-- correctness failure in which half the dashboards read a different model. +CREATE UNIQUE INDEX IF NOT EXISTS opt_analytics_semantic_model_one_active + ON opt_analytics_semantic_model_version (tenant_id, model_id) + WHERE status = 'active'; + +-- A published version's CONTENT is immutable; only its status and valid_from +-- may move (draft -> active -> superseded -> retired). +CREATE OR REPLACE FUNCTION opt_analytics_semantic_version_content_immutable() RETURNS trigger AS $$ +BEGIN + IF NEW.hash IS DISTINCT FROM OLD.hash + OR NEW.metrics_json IS DISTINCT FROM OLD.metrics_json + OR NEW.dimensions_json IS DISTINCT FROM OLD.dimensions_json + OR NEW.relationships_json IS DISTINCT FROM OLD.relationships_json + OR NEW.datasets IS DISTINCT FROM OLD.datasets + OR NEW.version IS DISTINCT FROM OLD.version THEN + RAISE EXCEPTION + 'BI-P030: semantic model % v% is immutable; publish a new version instead', + OLD.model_id, OLD.version + USING ERRCODE = 'restrict_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS opt_analytics_semantic_version_guard ON opt_analytics_semantic_model_version; +CREATE TRIGGER opt_analytics_semantic_version_guard + BEFORE UPDATE ON opt_analytics_semantic_model_version + FOR EACH ROW EXECUTE FUNCTION opt_analytics_semantic_version_content_immutable(); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- BI-P025 / BI-P026 — QUARANTINE AND FRESHNESS +-- ═══════════════════════════════════════════════════════════════════════════ +-- The quarantine record BI-P025's DoD acts on. The READ-side gate lives in the +-- application (`assertProjectionReadable`); this is its durable state, so a +-- process restart does not release a quarantine. +CREATE TABLE IF NOT EXISTS opt_analytics_projection_quarantine ( + tenant_id TEXT NOT NULL, + dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id), + quarantined BOOLEAN NOT NULL, + caused_by TEXT[] NOT NULL DEFAULT '{}', + reason TEXT, + since TIMESTAMPTZ, + released_at TIMESTAMPTZ, + released_by TEXT, + PRIMARY KEY (tenant_id, dataset_id), + -- Release is an ACT with an operator behind it, never an automatic + -- consequence of a later passing run: a flapping rule would otherwise + -- quarantine and un-quarantine a dataset between two reads of one dashboard. + CHECK (quarantined OR released_at IS NULL OR (released_by IS NOT NULL AND reason IS NOT NULL)) +); + +-- BI-P026 freshness state, per dataset. `UNKNOWN` is the DEFAULT, deliberately: +-- a dataset nobody has computed freshness for must not read as FRESH. +CREATE TABLE IF NOT EXISTS opt_analytics_dataset_freshness ( + tenant_id TEXT NOT NULL, + dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id), + state TEXT NOT NULL DEFAULT 'UNKNOWN' + CHECK (state IN ('FRESH','DELAYED','STALE','FAILED','UNKNOWN')), + age_ms BIGINT, + last_successful_load_at TIMESTAMPTZ, + last_run_at TIMESTAMPTZ, + last_run_status TEXT CHECK (last_run_status IN ('succeeded','failed','running','never_run')), + expected_interval_ms BIGINT NOT NULL, + disclosure TEXT NOT NULL, + computed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, dataset_id), + -- Every state must carry a disclosure a UI can render verbatim. A state with + -- no words attached is a state a dashboard will render as a silent green dot. + CHECK (length(btrim(disclosure)) > 0) +); + +-- ═══════════════════════════════════════════════════════════════════════════ +-- BI-P021 — USAGE LEDGER +-- ═══════════════════════════════════════════════════════════════════════════ +-- What CL0UD quotas, AUD0 usage and B00K accounting read. Append-only. +-- +-- `terminated_by_budget` is recorded because a query the budget killed still +-- COST what it burned before it died; not charging for it is how a tenant +-- learns that the cheapest query is one that gets cancelled. +CREATE TABLE IF NOT EXISTS opt_analytics_usage_ledger ( + usage_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + project_id TEXT, + principal_id TEXT NOT NULL, + query_id TEXT NOT NULL, + engine TEXT NOT NULL, + rows_scanned BIGINT NOT NULL DEFAULT 0, + bytes_scanned BIGINT NOT NULL DEFAULT 0, + storage_read_bytes BIGINT NOT NULL DEFAULT 0, + execution_ms BIGINT NOT NULL DEFAULT 0, + cost_units NUMERIC(20, 6) NOT NULL, + terminated_by_budget BOOLEAN NOT NULL DEFAULT false, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS opt_analytics_usage_ledger_window_idx + ON opt_analytics_usage_ledger (tenant_id, recorded_at DESC); + +DROP TRIGGER IF EXISTS opt_analytics_usage_ledger_immutable ON opt_analytics_usage_ledger; +CREATE TRIGGER opt_analytics_usage_ledger_immutable + BEFORE UPDATE OR DELETE ON opt_analytics_usage_ledger + FOR EACH ROW EXECUTE FUNCTION opt_analytics_forbid_mutation(); diff --git a/tests/analytics/analytics-governance-postgres.realinfra.test.ts b/tests/analytics/analytics-governance-postgres.realinfra.test.ts new file mode 100644 index 0000000..d92ce10 --- /dev/null +++ b/tests/analytics/analytics-governance-postgres.realinfra.test.ts @@ -0,0 +1,349 @@ +/** + * tests/analytics/analytics-governance-postgres.realinfra.test.ts + * ================================================================ + * sql/009 against a REAL Postgres. + * + * A migration nobody ran is a migration that does not work, and a trigger + * nobody fired is a comment. So this applies sql/008 and sql/009 into a + * throwaway schema and then attempts, against the database, every write the + * constraints exist to refuse: + * + * BI-P031 UPDATE a CERTIFIED metric's meaning in place -> must RAISE. + * This is the layer that holds when something writes without going + * through the application: a migration script, a psql session, an + * admin tool. A TypeScript guard does not survive `psql`. + * BI-P030 UPDATE a published semantic model version's content -> must RAISE. + * Two ACTIVE versions of one model -> must violate a unique index. + * BI-P028 A currency-formatted metric with no currency -> must be refused. + * Certification without an actor or the two definitions -> refused. + * BI-P018 A `compiled_query_hash` that is not a 64-hex digest -> refused. + * BI-P021 UPDATE or DELETE a usage-ledger row -> must RAISE (append-only). + * + * Gated on ANALYTICS_TEST_DATABASE_URL, matching the convention in + * analytics-domain-postgres.realinfra.test.ts. With it set and Postgres down, + * this FAILS rather than skipping. + * + * docker run -d --name pg -p 25433:5432 -e POSTGRES_PASSWORD=test \ + * -e POSTGRES_DB=opt postgres:16-alpine + * ANALYTICS_TEST_DATABASE_URL=postgres://postgres:test@127.0.0.1:25433/opt \ + * pnpm vitest run tests/analytics/analytics-governance-postgres.realinfra.test.ts + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import postgres from 'postgres'; + +const TEST_DATABASE_URL = process.env.ANALYTICS_TEST_DATABASE_URL; +const run = TEST_DATABASE_URL ? describe : describe.skip; +const SCHEMA = `bi_p009_${Date.now().toString(36)}`; + +let sql: ReturnType; + +/** Did the statement RAISE? Returns the message, or undefined if it succeeded. */ +async function refusalOf(fn: () => Promise): Promise { + try { + await fn(); + return undefined; + } catch (e) { + return String((e as Error).message); + } +} + +beforeAll(async () => { + if (!TEST_DATABASE_URL) return; + // The search_path is pinned on the CONNECTION rather than prefixed to every + // statement. Prefixing makes each call a multi-statement simple query, and + // postgres.js then returns the LAST statement's result — which silently + // turned `SELECT state FROM ...` into the connection object in the first + // version of this file, and made two assertions compare against nonsense. + const admin = postgres(TEST_DATABASE_URL, { max: 1, onnotice: () => undefined }); + await admin.unsafe(`CREATE SCHEMA IF NOT EXISTS ${SCHEMA}`); + await admin.end({ timeout: 5 }); + + sql = postgres(TEST_DATABASE_URL, { + max: 2, + onnotice: () => undefined, + connection: { search_path: SCHEMA }, + }); + for (const file of [ + 'sql/008_analytics_domain_model.sql', + 'sql/009_analytics_execution_metrics_governance.sql', + ]) { + await sql.unsafe(fs.readFileSync(path.join(process.cwd(), file), 'utf8')); + } + + // A dataset and a metric to govern. + await sql.unsafe(` + INSERT INTO opt_analytics_dataset + (dataset_id, tenant_id, project_id, name, tier, schema_contract_id, schema_contract_version, + owner_id, source_ref, freshness_slo_seconds, retention_days, classification, status) + VALUES ('ds_sales','t_alpha','p_main','sales','SERVING','c_sales','1.0.0', + 'u_1','0pt://sourcing/sales', 3600, 90, 'none', 'active') + ON CONFLICT DO NOTHING;`); +}, 120_000); + +afterAll(async () => { + if (!TEST_DATABASE_URL || !sql) return; + await sql.unsafe(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`); + await sql.end({ timeout: 5 }); +}); + +run('sql/009 applies, and replays', () => { + it('is idempotent — a replay onto an already-migrated schema succeeds', async () => { + const text = fs.readFileSync( + path.join(process.cwd(), 'sql/009_analytics_execution_metrics_governance.sql'), + 'utf8', + ); + // The restore drill replays every file in order onto a live database. + await expect(sql.unsafe(text)).resolves.toBeDefined(); + }); + + it('added every BI-P018 column the execution record needs', async () => { + const rows = await sql.unsafe( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = '${SCHEMA}' AND table_name = 'opt_analytics_query_execution'`, + ); + const names = new Set((rows as unknown as Array<{ column_name: string }>).map((r) => r.column_name)); + for (const col of [ + 'principal_id', + 'workload', + 'compiled_query_hash', + 'datasets', + 'metrics', + 'cache_status', + 'cost_units', + ]) { + expect(names.has(col), `missing ${col}`).toBe(true); + } + // And the one it must NOT have. + expect(names.has('query_text')).toBe(false); + expect(names.has('sql')).toBe(false); + }); +}); + +run('BI-P031 — a CERTIFIED metric cannot be modified in place, enforced by the DATABASE', () => { + const base = ''; + + beforeAll(async () => { + await sql.unsafe(`${base} + INSERT INTO opt_analytics_metric_definition + (metric_id, tenant_id, project_id, name, dataset_id, aggregation, measure_field, + allowed_dimension_ids, current_version, owner_id, description, + metric_type, time_dimension, unit, currency, display_format, status, filters_json, + certification, business_definition, technical_definition, meaning_hash, + certified_at, certified_by) + VALUES ('m_revenue','t_alpha','p_main','net_revenue','ds_sales','sum','amount_sek', + ARRAY['country'], 3, 'finance@siax.io', 'Invoiced revenue net of credits', + 'simple','invoiced_at','SEK','SEK','currency','ACTIVE','[]', + 'certified','What we invoiced','sum(amount_sek)', repeat('a',64), + now(), 'u_1') + ON CONFLICT (metric_id) DO NOTHING;`); + }); + + it('REFUSES an in-place change to the meaning hash', async () => { + const message = await refusalOf(() => + sql.unsafe(`${base} UPDATE opt_analytics_metric_definition + SET meaning_hash = repeat('b',64) WHERE metric_id = 'm_revenue'`), + ); + expect(message, "a certified metric's meaning was changed in place, in the database").toBeDefined(); + expect(message).toContain('BI-P031'); + expect(message).toContain('mint version 4'); + }); + + it('ALLOWS an editorial change that leaves the meaning hash alone', async () => { + await expect( + sql.unsafe(`${base} UPDATE opt_analytics_metric_definition + SET description = 'Clearer wording' WHERE metric_id = 'm_revenue'`), + ).resolves.toBeDefined(); + }); + + it('ALLOWS a meaning change that comes with a version bump', async () => { + await expect( + sql.unsafe(`${base} UPDATE opt_analytics_metric_definition + SET meaning_hash = repeat('c',64), current_version = 4 + WHERE metric_id = 'm_revenue'`), + ).resolves.toBeDefined(); + // Put it back for the remaining assertions. + await sql.unsafe(`${base} UPDATE opt_analytics_metric_definition + SET current_version = 3 WHERE metric_id = 'm_revenue'`); + }); + + it('refuses a currency-formatted metric with no currency', async () => { + const message = await refusalOf(() => + sql.unsafe(`${base} INSERT INTO opt_analytics_metric_definition + (metric_id, tenant_id, project_id, name, dataset_id, aggregation, measure_field, + owner_id, display_format, currency, time_dimension) + VALUES ('m_bad','t_alpha','p_main','bad_metric','ds_sales','sum','x','u','currency',NULL,'t')`), + ); + expect(message).toContain('currency_pairing'); + }); + + it('refuses a currency on a non-currency format', async () => { + const message = await refusalOf(() => + sql.unsafe(`${base} INSERT INTO opt_analytics_metric_definition + (metric_id, tenant_id, project_id, name, dataset_id, aggregation, measure_field, + owner_id, display_format, currency, time_dimension) + VALUES ('m_bad2','t_alpha','p_main','bad_metric2','ds_sales','sum','x','u','integer','SEK','t')`), + ); + expect(message).toContain('currency_pairing'); + }); + + it('refuses certification without an actor, a time, and both definitions', async () => { + const message = await refusalOf(() => + sql.unsafe(`${base} INSERT INTO opt_analytics_metric_definition + (metric_id, tenant_id, project_id, name, dataset_id, aggregation, measure_field, + owner_id, display_format, time_dimension, certification) + VALUES ('m_bad3','t_alpha','p_main','bad_metric3','ds_sales','sum','x','u','decimal','t','certified')`), + ); + expect(message).toContain('certification_act'); + }); + + it('refuses an unknown lifecycle status', async () => { + const message = await refusalOf(() => + sql.unsafe(`${base} UPDATE opt_analytics_metric_definition + SET status = 'PROBABLY_FINE' WHERE metric_id = 'm_revenue'`), + ); + expect(message).toContain('metric_status'); + }); +}); + +run('BI-P030 — a published semantic model version is immutable, and only one is active', () => { + const base = ''; + + beforeAll(async () => { + for (const [id, version, status] of [ + ['smv_1', 1, 'active'], + ['smv_2', 2, 'draft'], + ] as const) { + await sql.unsafe(`${base} + INSERT INTO opt_analytics_semantic_model_version + (semantic_model_version_id, model_id, tenant_id, project_id, version, + datasets, relationships_json, metrics_json, dimensions_json, status, hash) + VALUES ('${id}','sm_sales','t_alpha','p_main',${version}, + ARRAY['ds_sales'],'[]','[]','[]','${status}', repeat('${version}',64)) + ON CONFLICT DO NOTHING;`); + } + }); + + it("REFUSES an update to a published version's content", async () => { + const message = await refusalOf(() => + sql.unsafe(`${base} UPDATE opt_analytics_semantic_model_version + SET metrics_json = '[{"id":"m_new"}]' WHERE semantic_model_version_id = 'smv_1'`), + ); + expect(message, 'a pinned semantic model version was rewritten under a dashboard').toBeDefined(); + expect(message).toContain('BI-P030'); + }); + + it('ALLOWS a status transition, which is not a content change', async () => { + await expect( + sql.unsafe(`${base} UPDATE opt_analytics_semantic_model_version + SET status = 'superseded' WHERE semantic_model_version_id = 'smv_1'`), + ).resolves.toBeDefined(); + await sql.unsafe(`${base} UPDATE opt_analytics_semantic_model_version + SET status = 'active' WHERE semantic_model_version_id = 'smv_1'`); + }); + + it('refuses TWO active versions of the same model — half the dashboards would read the other one', async () => { + const message = await refusalOf(() => + sql.unsafe(`${base} UPDATE opt_analytics_semantic_model_version + SET status = 'active' WHERE semantic_model_version_id = 'smv_2'`), + ); + expect(message).toContain('opt_analytics_semantic_model_one_active'); + }); + + it('refuses a hash that is not a sha256 digest', async () => { + const message = await refusalOf(() => + sql.unsafe(`${base} + INSERT INTO opt_analytics_semantic_model_version + (semantic_model_version_id, model_id, tenant_id, project_id, version, + datasets, relationships_json, metrics_json, dimensions_json, status, hash) + VALUES ('smv_bad','sm_sales','t_alpha','p_main',9,ARRAY['ds_sales'],'[]','[]','[]','draft','not-a-hash')`), + ); + expect(message).toBeDefined(); + }); +}); + +run('BI-P018 / BI-P021 — the execution record and the usage ledger', () => { + const base = ''; + + it('refuses a compiled_query_hash that is not a 64-hex digest', async () => { + const message = await refusalOf(() => + sql.unsafe(`${base} INSERT INTO opt_analytics_query_execution + (execution_id, tenant_id, project_id, engine_query_id, state, provider, compiled_query_hash) + VALUES ('e_bad','t_alpha','p_main','q1','queued','clickhouse','SELECT * FROM sales')`), + ); + expect(message).toContain('hash_shape'); + }); + + it('accepts a proper execution record', async () => { + await expect( + sql.unsafe(`${base} INSERT INTO opt_analytics_query_execution + (execution_id, tenant_id, project_id, engine_query_id, state, provider, + compiled_query_hash, principal_id, workload, cache_status, cost_units) + VALUES ('e_ok','t_alpha','p_main','q1','succeeded','clickhouse', + repeat('a',64),'u_1','dashboard','hit',0.5)`), + ).resolves.toBeDefined(); + }); + + it('refuses an unknown cache status and an unknown workload', async () => { + expect( + await refusalOf(() => + sql.unsafe(`${base} UPDATE opt_analytics_query_execution + SET cache_status = 'probably' WHERE execution_id = 'e_ok'`), + ), + ).toContain('cache_status'); + expect( + await refusalOf(() => + sql.unsafe(`${base} UPDATE opt_analytics_query_execution + SET workload = 'vibes' WHERE execution_id = 'e_ok'`), + ), + ).toContain('workload'); + }); + + it('the usage ledger is APPEND-ONLY — update and delete both raise', async () => { + await sql.unsafe(`${base} INSERT INTO opt_analytics_usage_ledger + (usage_id, tenant_id, principal_id, query_id, engine, rows_scanned, bytes_scanned, + execution_ms, cost_units, terminated_by_budget) + VALUES ('u_1','t_alpha','p_1','q1','clickhouse',1000,2000,50,0.25,true)`); + + expect( + await refusalOf(() => + sql.unsafe(`${base} UPDATE opt_analytics_usage_ledger SET cost_units = 0 WHERE usage_id = 'u_1'`), + ), + 'a billing row was editable', + ).toBeDefined(); + expect( + await refusalOf(() => + sql.unsafe(`${base} DELETE FROM opt_analytics_usage_ledger WHERE usage_id = 'u_1'`), + ), + 'a billing row was deletable', + ).toBeDefined(); + }); + + it('an analyst statement refusal must carry a reason', async () => { + const message = await refusalOf(() => + sql.unsafe(`${base} INSERT INTO opt_analytics_sql_statement + (statement_id, tenant_id, principal_id, statement_normalized, statement_hash, outcome) + VALUES ('s_bad','t_alpha','u_1','SELECT ?', repeat('a',64), 'refused')`), + ); + expect(message, 'a refusal with no reason was accepted — an unreviewable audit trail').toBeDefined(); + }); + + it('a freshness row must carry a disclosure a UI can render', async () => { + const message = await refusalOf(() => + sql.unsafe(`${base} INSERT INTO opt_analytics_dataset_freshness + (tenant_id, dataset_id, state, expected_interval_ms, disclosure) + VALUES ('t_alpha','ds_sales','STALE',3600000,' ')`), + ); + expect(message).toBeDefined(); + }); + + it('freshness defaults to UNKNOWN, never to FRESH', async () => { + await sql.unsafe(`${base} INSERT INTO opt_analytics_dataset_freshness + (tenant_id, dataset_id, expected_interval_ms, disclosure) + VALUES ('t_beta','ds_sales',3600000,'not yet computed')`); + const rows = await sql.unsafe(`${base} SELECT state FROM opt_analytics_dataset_freshness + WHERE tenant_id = 't_beta'`); + expect((rows as unknown as Array<{ state: string }>)[0].state).toBe('UNKNOWN'); + }); +});