fix(recovery): restore source removed by production documentation merge
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
-- 0pt — initial schema (docs/03-data-model.md). Postgres + pgvector.
|
||||
-- Amounts in öre (integer). Every fact links to bridge_provenance.
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- ── Data entities ───────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS dim_source (
|
||||
source_id text PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
market text,
|
||||
access text, -- api | bulk | authorized_web | licensed
|
||||
licence text,
|
||||
cadence text,
|
||||
url text
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dim_company (
|
||||
org_nr text PRIMARY KEY,
|
||||
market text NOT NULL,
|
||||
lei text,
|
||||
name text NOT NULL,
|
||||
status text,
|
||||
nace text,
|
||||
registered_at date,
|
||||
address jsonb,
|
||||
website text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fact_financials (
|
||||
org_nr text REFERENCES dim_company(org_nr),
|
||||
fiscal_year int NOT NULL,
|
||||
revenue bigint, result bigint, employees int,
|
||||
source_id text REFERENCES dim_source(source_id),
|
||||
PRIMARY KEY (org_nr, fiscal_year)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dim_contact (
|
||||
contact_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
org_nr text REFERENCES dim_company(org_nr),
|
||||
name text, role text, email text, phone text, linkedin text,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dim_ubo (
|
||||
ubo_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
org_nr text REFERENCES dim_company(org_nr),
|
||||
owner text, share numeric(5,2)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dim_vehicle (
|
||||
vin text PRIMARY KEY,
|
||||
market text NOT NULL,
|
||||
reg_plate text, make text, model text, year int,
|
||||
owner_org text REFERENCES dim_company(org_nr),
|
||||
specs jsonb,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ── Provenance (the signature) ──────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS bridge_provenance (
|
||||
field_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entity_type text NOT NULL, -- company|contact|vehicle|financials
|
||||
entity_key text NOT NULL,
|
||||
field_name text NOT NULL,
|
||||
source_id text REFERENCES dim_source(source_id),
|
||||
freshness date,
|
||||
confidence numeric(4,3) NOT NULL,
|
||||
level smallint NOT NULL, -- 0 unavailable/licensed,1 single,2 partial,3 corroborated
|
||||
agreed_sources int DEFAULT 1,
|
||||
UNIQUE (entity_type, entity_key, field_name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_prov_entity ON bridge_provenance (entity_type, entity_key);
|
||||
|
||||
-- ── Platform tables ─────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS orgs (
|
||||
org_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL, created_at timestamptz DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
org_id uuid REFERENCES orgs(org_id),
|
||||
email text UNIQUE NOT NULL,
|
||||
role text NOT NULL DEFAULT 'member',
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
order_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
org_id uuid REFERENCES orgs(org_id),
|
||||
flow text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'draft',
|
||||
config jsonb NOT NULL,
|
||||
total_ore bigint, deposit_ore bigint, balance_ore bigint,
|
||||
est_days int, est_confidence numeric(4,3),
|
||||
created_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
order_id uuid REFERENCES orders(order_id),
|
||||
stage text, pct numeric(5,2) DEFAULT 0, state text DEFAULT 'queued',
|
||||
metrics jsonb, started_at timestamptz, finished_at timestamptz
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS deliveries (
|
||||
delivery_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
order_id uuid REFERENCES orders(order_id),
|
||||
formats text[], destinations text[],
|
||||
record_count int, avg_confidence numeric(4,3),
|
||||
quality_report jsonb, provenance_export_url text, files jsonb,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
sub_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
order_id uuid REFERENCES orders(order_id),
|
||||
cadence text, webhook_url text, status text DEFAULT 'active', next_run_at timestamptz
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
file_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
org_id uuid REFERENCES orgs(org_id), order_id uuid REFERENCES orders(order_id),
|
||||
name text, mime text, bytes bigint, r2_key text, created_at timestamptz DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS db_grants (
|
||||
grant_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
org_id uuid REFERENCES orgs(org_id), order_id uuid REFERENCES orders(order_id),
|
||||
conn_encrypted bytea NOT NULL, scope text, expires_at timestamptz, revoked_at timestamptz,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS payments (
|
||||
payment_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
order_id uuid REFERENCES orders(order_id),
|
||||
kind text, amount_ore bigint, stripe_ref text, status text,
|
||||
created_at timestamptz DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
actor text, action text, target text, meta jsonb, at timestamptz DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,105 @@
|
||||
-- 0pt Source Registry persistence (Master-DoD §10; BEFORE_STATE.md §1).
|
||||
--
|
||||
-- Backs src/services/source-service.ts (0pt.source.schema.v1, the base
|
||||
-- registry) and src/services/source-registry-service.ts (rights/license/
|
||||
-- purpose, provenance, health, freshness — the P0 Source Registry surface
|
||||
-- layered on top). Both previously held their state in bare `new Map()`s,
|
||||
-- scoped per service *instance* (not even shared within one process) and
|
||||
-- hard-blocked from production boot by resolveStoreMode
|
||||
-- (src/platform/dependency-mode.ts) for lack of a DB. This migration is the
|
||||
-- DB half of closing that gap; src/repositories/* is the code half.
|
||||
--
|
||||
-- Deliberately numbered 002, not 006: sql/004_iceberg_write_boundary.sql's
|
||||
-- opt_source_rate_policies table already carries
|
||||
-- `source_id TEXT NOT NULL REFERENCES opt_sources (source_id)` — a
|
||||
-- forward reference to a table that has never existed in any sql/ file
|
||||
-- (opt_sources was, until this file, only ever a doc-comment aspiration —
|
||||
-- see the "What Postgres migration would require" section of
|
||||
-- BEFORE_STATE.md §1 and the retracted `sql/002_p0_dataset_quality.sql`
|
||||
-- claim in CLAUDE.md/app/api/0pt/sources/[sourceId]/query/route.ts, an
|
||||
-- UNRELATED fabricated claim about a different, never-written file that
|
||||
-- happened to reuse this same number). Applying 004 before opt_sources
|
||||
-- exists would fail outright on a fresh database; this file must run
|
||||
-- between 001 and 004 to make 004's existing FK resolvable. This is a
|
||||
-- pre-existing ordering bug in 004, not introduced here — fixed here only
|
||||
-- because this pass needed opt_sources to exist anyway.
|
||||
|
||||
-- ── 0pt.source.schema.v1 (src/types/source.ts) ─────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_sources (
|
||||
source_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL, -- database | file | web | api | stream | manual
|
||||
name TEXT NOT NULL,
|
||||
adapter_id TEXT, -- e.g. "fabric_onelake_sql" (src/adapters/fabric-onelake-adapter.ts)
|
||||
access TEXT, -- api | bulk | authorized_web | licensed (never a bypass mode)
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active', -- active | paused | archived | deprecated
|
||||
correlation_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
created_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_sources_tenant_project_idx ON opt_sources (tenant_id, project_id);
|
||||
CREATE INDEX IF NOT EXISTS opt_sources_type_idx ON opt_sources (source_type);
|
||||
CREATE INDEX IF NOT EXISTS opt_sources_status_idx ON opt_sources (status);
|
||||
|
||||
-- ── Rights / license / purpose metadata (src/types/source-registry.ts) ─
|
||||
-- One row per source (SourceRegistryService.setRights is an upsert-by-
|
||||
-- source_id, same as the Map it replaces). No rights row = fail-closed
|
||||
-- deny in assertPurposeAllowed — that logic lives in the service, not here.
|
||||
CREATE TABLE IF NOT EXISTS opt_source_rights (
|
||||
source_id TEXT PRIMARY KEY REFERENCES opt_sources (source_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
license_id TEXT NOT NULL,
|
||||
license_url TEXT,
|
||||
allowed_purposes TEXT[] NOT NULL DEFAULT '{}',
|
||||
pii_allowed BOOLEAN NOT NULL DEFAULT false,
|
||||
redistribution TEXT NOT NULL, -- internal | customer_deliverable | public | prohibited
|
||||
attribution TEXT,
|
||||
verified_at TEXT NOT NULL, -- ISO date the assessment was made (date-only in practice; kept as TEXT, not TIMESTAMPTZ, so it round-trips byte-for-byte)
|
||||
verified_by TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_source_rights_tenant_idx ON opt_source_rights (tenant_id);
|
||||
|
||||
-- ── Provenance / method metadata (per-SOURCE, not per-field — see
|
||||
-- BEFORE_STATE.md §3 for the distinction from bridge_provenance) ────────
|
||||
CREATE TABLE IF NOT EXISTS opt_source_provenance (
|
||||
source_id TEXT PRIMARY KEY REFERENCES opt_sources (source_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
method TEXT NOT NULL, -- official_api | bulk_download | authorized_web | licensed_feed | manual_upload
|
||||
authority TEXT,
|
||||
compliance_evidence_ref TEXT,
|
||||
cadence TEXT,
|
||||
freshness_sla_hours NUMERIC,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_source_provenance_tenant_idx ON opt_source_provenance (tenant_id);
|
||||
|
||||
-- ── Health observations (source.health) — an operational signal, NOT an
|
||||
-- audit log (AUD0 owns history). Bounded to the latest 100 rows per
|
||||
-- source_id by the repository layer (src/repositories/postgres-source-
|
||||
-- registry-repository.ts), mirroring the in-memory implementation's
|
||||
-- `list.splice(0, list.length - 100)` bound.
|
||||
CREATE TABLE IF NOT EXISTS opt_source_health_checks (
|
||||
health_check_id BIGSERIAL PRIMARY KEY,
|
||||
source_id TEXT NOT NULL REFERENCES opt_sources (source_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- up | degraded | down | unknown
|
||||
checked_at TIMESTAMPTZ NOT NULL,
|
||||
latency_ms INT,
|
||||
detail TEXT -- short operator-facing text; never a raw upstream body
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_source_health_checks_source_idx ON opt_source_health_checks (source_id, checked_at DESC, health_check_id DESC);
|
||||
|
||||
-- ── Freshness: last successful observation per source (source.health's
|
||||
-- "up" checks call recordSuccess; getFreshness reads this + provenance's
|
||||
-- freshness_sla_hours). One row per source — upserted, not appended.
|
||||
CREATE TABLE IF NOT EXISTS opt_source_freshness (
|
||||
source_id TEXT PRIMARY KEY REFERENCES opt_sources (source_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
last_success_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_source_freshness_tenant_idx ON opt_source_freshness (tenant_id);
|
||||
@@ -0,0 +1,41 @@
|
||||
-- P014: fills a real, previously-undiscovered gap in the migration chain.
|
||||
-- sql/004_iceberg_write_boundary.sql's own comment already documented (but
|
||||
-- did not fix) that `opt_dataset_iceberg_mappings.dataset_id REFERENCES
|
||||
-- opt_datasets (dataset_id)` points at a table NO sql/ file ever defined --
|
||||
-- meaning `psql -d opt < sql/001... < sql/002... < sql/004...` (exactly the
|
||||
-- flow docs/runbook/backup-restore.md's own "Målbild" describes, and exactly
|
||||
-- what a real restore drill executes) has never actually been runnable
|
||||
-- end to end. This was found by running that exact sequence for real against
|
||||
-- a throwaway Postgres container during P014's restore drill, not by
|
||||
-- inspecting the code.
|
||||
--
|
||||
-- Numbered 003 (the previously-unused gap between 002_source_registry.sql
|
||||
-- and 004_iceberg_write_boundary.sql) so the existing ordering -- 001 -> 002
|
||||
-- -> 004 -> 005 -- becomes 001 -> 002 -> 003 -> 004 -> 005 without renumbering
|
||||
-- anything already shipped.
|
||||
--
|
||||
-- Columns mirror src/services/dataset-service.ts's `DatasetRecord` exactly
|
||||
-- (that file's own doc comment: "Storage shape: nullable columns, exactly as
|
||||
-- the eventual `opt_datasets` table would hold them") -- this is the real
|
||||
-- application contract, not a guess. `dataset_type`/`status` CHECK
|
||||
-- constraints mirror src/types/dataset.ts's `DatasetType`/`DatasetStatus`
|
||||
-- unions verbatim. DatasetService itself remains in-memory-only in this
|
||||
-- pass (wiring it to this table is a separate, larger change than closing
|
||||
-- the migration-chain gap); this table exists so the SCHEMA a fresh
|
||||
-- database gets by replaying sql/*.sql matches what 004/005 already assume.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_datasets (
|
||||
dataset_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
dataset_type TEXT NOT NULL CHECK (dataset_type IN ('raw', 'structured', 'enriched', 'curated', 'feature', 'derived')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deprecated')),
|
||||
description TEXT,
|
||||
correlation_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
created_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS opt_datasets_tenant_project_idx ON opt_datasets (tenant_id, project_id);
|
||||
@@ -0,0 +1,62 @@
|
||||
-- 0pt Iceberg write-path ownership repair (docs/ICEBERG_WRITE_BOUNDARY.md)
|
||||
-- Two tables: a REFERENCE from a 0pt dataset to its Iceberg table in ST0RE's
|
||||
-- table registry, and 0pt's own source-rate/crawl-compliance policy (moved
|
||||
-- out of N0D per ADR-0030's audit -- see docs/10-lakehouse-decision.md).
|
||||
--
|
||||
-- opt_dataset_iceberg_mappings.iceberg_table_id is intentionally NOT a
|
||||
-- foreign key -- it references ST0RE's st0re_iceberg_tables.table_id, a
|
||||
-- different service's database. 0pt holds only the reference, never a copy
|
||||
-- of ST0RE's table/catalog/snapshot state (see the module doc comment on
|
||||
-- src/clients/st0re-lakehouse-client.ts).
|
||||
--
|
||||
-- Ordering note (added by the Source Registry Postgres-persistence pass):
|
||||
-- opt_source_rate_policies below has always forward-referenced
|
||||
-- `opt_sources (source_id)`, a table no sql/ file defined until
|
||||
-- sql/002_source_registry.sql landed -- applying this file to a fresh
|
||||
-- database before 002 would fail on that FK. 002 is now the one that must
|
||||
-- run first (001 -> 002 -> 004 -> 005). Flagging, not fixing here: this
|
||||
-- file's OTHER forward reference, `opt_dataset_iceberg_mappings.dataset_id
|
||||
-- REFERENCES opt_datasets (dataset_id)` below, has the identical problem --
|
||||
-- no sql/ file defines opt_datasets either -- and remains open; it is a
|
||||
-- dataset-registry gap, unrelated to and out of scope for the Source
|
||||
-- Registry work that added 002.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_dataset_iceberg_mappings (
|
||||
dataset_iceberg_mapping_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_datasets (dataset_id),
|
||||
iceberg_table_id TEXT NOT NULL,
|
||||
namespace TEXT,
|
||||
table_name TEXT,
|
||||
mapping_status TEXT NOT NULL DEFAULT 'active',
|
||||
mapped_by TEXT,
|
||||
correlation_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS opt_dataset_iceberg_mappings_dataset_idx ON opt_dataset_iceberg_mappings (dataset_id);
|
||||
CREATE INDEX IF NOT EXISTS opt_dataset_iceberg_mappings_tenant_project_idx ON opt_dataset_iceberg_mappings (tenant_id, project_id);
|
||||
CREATE INDEX IF NOT EXISTS opt_dataset_iceberg_mappings_table_idx ON opt_dataset_iceberg_mappings (iceberg_table_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_source_rate_policies (
|
||||
source_rate_policy_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
source_id TEXT NOT NULL REFERENCES opt_sources (source_id),
|
||||
rate_limit_per_minute NUMERIC NOT NULL,
|
||||
crawl_budget NUMERIC NOT NULL,
|
||||
compliance_notes TEXT,
|
||||
domain TEXT,
|
||||
robots_ok BOOLEAN,
|
||||
access TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
correlation_id TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
created_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS opt_source_rate_policies_source_idx ON opt_source_rate_policies (source_id);
|
||||
CREATE INDEX IF NOT EXISTS opt_source_rate_policies_tenant_project_idx ON opt_source_rate_policies (tenant_id, project_id);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 0pt Iceberg write-path ownership: field-level gap closure pass
|
||||
-- (docs/ICEBERG_WRITE_BOUNDARY.md "Live verification" section, 2026-07-13).
|
||||
--
|
||||
-- 004_iceberg_write_boundary.sql shipped opt_dataset_iceberg_mappings without
|
||||
-- namespace_id/mapped_at. This session's task spec names those fields
|
||||
-- literally (0pt.dataset_iceberg_mapping.schema.v1: dataset_id,
|
||||
-- iceberg_table_id, namespace_id, mapped_at, mapped_by), and this session
|
||||
-- also live-verified (real HTTP calls against api.st0re.siax.io) that
|
||||
-- ST0RE's namespace registry (/v1/st0re/lakehouse/namespaces) returns a real
|
||||
-- namespaceId distinct from the bare namespace name -- so namespace_id is a
|
||||
-- genuine reference, not a renamed duplicate of the existing `namespace`
|
||||
-- column. Both new columns are nullable additive columns; no data migration
|
||||
-- needed since 004 has not been applied to a live database by any session
|
||||
-- (same honest status as 002/003 before it).
|
||||
|
||||
ALTER TABLE opt_dataset_iceberg_mappings
|
||||
ADD COLUMN IF NOT EXISTS namespace_id TEXT,
|
||||
ADD COLUMN IF NOT EXISTS mapped_at TIMESTAMPTZ NOT NULL DEFAULT now();
|
||||
|
||||
CREATE INDEX IF NOT EXISTS opt_dataset_iceberg_mappings_namespace_idx ON opt_dataset_iceberg_mappings (namespace_id);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- P014: duplicate-delivery idempotency for POST /api/orders (docs/04, §01).
|
||||
-- lib/orders-store.ts previously had NO idempotency mechanism at all -- a
|
||||
-- retried POST (a prospect double-clicking "submit", a client retrying on a
|
||||
-- dropped response, a proxy replaying a request) created a second, distinct
|
||||
-- `orders` row for what was semantically the same submission every time,
|
||||
-- with no way to detect or collapse the duplicate afterward (P014 recon:
|
||||
-- "Duplicate delivery idempotent: NOT MET").
|
||||
--
|
||||
-- `idempotency_key` is caller-supplied (the standard `Idempotency-Key`
|
||||
-- header convention this skill's own docs already name as the expected
|
||||
-- 0pt/orders convention) and OPTIONAL: an order created with no key behaves
|
||||
-- exactly as before (multiple distinct rows on retry) -- this migration adds
|
||||
-- a mechanism, it does not retroactively require every caller to use it.
|
||||
-- When present, it must be unique across ALL orders (not scoped per-org):
|
||||
-- `/api/orders` POST is intentionally anonymous (a prospect can draft/quote
|
||||
-- before signing in -- see app/api/orders/route.ts), so there is no org_id
|
||||
-- to scope by at creation time; a caller-generated key (e.g. a UUID minted
|
||||
-- once per logical submission) is expected to be globally unique on its own,
|
||||
-- matching the standard Stripe-style idempotency-key contract.
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS idempotency_key text;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS orders_idempotency_key_uq
|
||||
ON orders (idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
@@ -0,0 +1,162 @@
|
||||
-- 0pt ingestion plane (P060-P068).
|
||||
--
|
||||
-- Adds the durable state the Source Registry and the ingestion runner need
|
||||
-- and that sql/002_source_registry.sql did NOT carry:
|
||||
--
|
||||
-- * opt_source_authorization -- P060 "authorization": the recorded decision
|
||||
-- that this tenant may use this source at all. Separate from rights
|
||||
-- (license/purpose) on purpose: rights say WHAT the data may be used for,
|
||||
-- authorization says WHO signed off that 0pt may connect. Both are
|
||||
-- required to activate.
|
||||
-- * opt_source_schedule -- P060 "schedule": ingestion mode + cadence.
|
||||
-- * opt_source_schemas -- P060 "schema" + P063 schema evolution:
|
||||
-- one row per (source_id, schema_version), plus the compatibility policy
|
||||
-- that governs the NEXT version.
|
||||
-- * opt_ingestion_checkpoints -- P060 "checkpoint" + P062: the durable
|
||||
-- resume point per (source_id, partition_key). This is the table that
|
||||
-- makes "restart resumes without silent gaps" true rather than aspirational.
|
||||
-- * opt_ingestion_ledger -- P068: idempotency ledger. One row per
|
||||
-- (source_id, record_key, payload hash); a repeated backfill that
|
||||
-- re-delivers the same record is recognised, not re-emitted.
|
||||
-- * opt_quality_quarantine -- P064: where envelopes go when the declared
|
||||
-- enforcement policy says "quarantine" instead of "block".
|
||||
-- * opt_lineage_edges -- P067: the LOCAL, QUERYABLE projection of
|
||||
-- the OpenLineage events 0pt already emits to AUD0. AUD0 remains the
|
||||
-- system of record; this table exists because "emitted to AUD0" is not
|
||||
-- the same thing as "queryable from 0pt", and the P067 DoD asks for the
|
||||
-- latter.
|
||||
--
|
||||
-- Every statement is IF NOT EXISTS / idempotent so it can be applied to an
|
||||
-- already-migrated database, matching sql/002_source_registry.sql's contract
|
||||
-- (tests/p0/*.realinfra.test.ts apply the files themselves).
|
||||
--
|
||||
-- Ordering: requires sql/002_source_registry.sql (opt_sources) to have run.
|
||||
|
||||
-- ── P060 owner + the pre-activation status ────────────────────────────────
|
||||
-- `created_by` (002) records who typed the registration; `owner_id` records
|
||||
-- who is ACCOUNTABLE for the source afterwards. The activation gate requires
|
||||
-- the latter, so they cannot be collapsed.
|
||||
ALTER TABLE opt_sources ADD COLUMN IF NOT EXISTS owner_id TEXT;
|
||||
CREATE INDEX IF NOT EXISTS opt_sources_owner_idx ON opt_sources (owner_id);
|
||||
|
||||
-- New sources are created 'pending' (see src/repositories/*-source-repository.ts).
|
||||
-- The column default in 002 is 'active'; it is changed here so a plain INSERT
|
||||
-- that omits `status` -- from any client, including psql -- cannot produce an
|
||||
-- unclassified-but-active source. Rows that are already 'active' are left
|
||||
-- exactly as they are: this migration does not retroactively deactivate
|
||||
-- anything an operator has already approved.
|
||||
ALTER TABLE opt_sources ALTER COLUMN status SET DEFAULT 'pending';
|
||||
|
||||
-- ── P060 authorization ────────────────────────────────────────────────────
|
||||
-- One row per source. `basis` is the legal/contractual ground; `granted_by`
|
||||
-- is the accountable actor id (never "system"); `evidence_ref` points at the
|
||||
-- AUD0 evidence or 1p/contract artifact. `expires_at` is honoured by the
|
||||
-- activation gate -- an expired authorization does not classify a source.
|
||||
CREATE TABLE IF NOT EXISTS opt_source_authorization (
|
||||
source_id TEXT PRIMARY KEY REFERENCES opt_sources (source_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
basis TEXT NOT NULL, -- contract | public_register | consent | legitimate_interest | internal
|
||||
granted_by TEXT NOT NULL, -- accountable actor id
|
||||
granted_at TIMESTAMPTZ NOT NULL,
|
||||
expires_at TIMESTAMPTZ,
|
||||
evidence_ref TEXT, -- aud0 evidence ref / contract artifact ref
|
||||
notes TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_source_authorization_tenant_idx ON opt_source_authorization (tenant_id);
|
||||
|
||||
-- ── P060 schedule ─────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_source_schedule (
|
||||
source_id TEXT PRIMARY KEY REFERENCES opt_sources (source_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
mode TEXT NOT NULL, -- batch | stream | cdc
|
||||
interval_seconds INT, -- batch/cdc poll cadence; NULL for continuous streams
|
||||
cron TEXT, -- optional cron expression (documented, not executed here)
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_source_schedule_tenant_idx ON opt_source_schedule (tenant_id);
|
||||
|
||||
-- ── P060 schema + P063 schema evolution ───────────────────────────────────
|
||||
-- `fields` is the record schema as JSON: {"<field>": {"type": "...",
|
||||
-- "required": true|false}}. `compatibility` governs what the NEXT registered
|
||||
-- version is allowed to change (see src/schema/compatibility.ts).
|
||||
CREATE TABLE IF NOT EXISTS opt_source_schemas (
|
||||
source_id TEXT NOT NULL REFERENCES opt_sources (source_id),
|
||||
schema_version INT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
fields JSONB NOT NULL,
|
||||
compatibility TEXT NOT NULL, -- backward | forward | full | none
|
||||
registered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
registered_by TEXT,
|
||||
PRIMARY KEY (source_id, schema_version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_source_schemas_tenant_idx ON opt_source_schemas (tenant_id);
|
||||
|
||||
-- ── P060 checkpoint + P062 durable resume ─────────────────────────────────
|
||||
-- `cursor` is opaque to 0pt -- the connector defines it (an offset, an LSN,
|
||||
-- a page token). `sequence` is 0pt's own monotonic envelope counter for the
|
||||
-- partition, used to detect gaps on resume.
|
||||
CREATE TABLE IF NOT EXISTS opt_ingestion_checkpoints (
|
||||
source_id TEXT NOT NULL REFERENCES opt_sources (source_id),
|
||||
partition_key TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
cursor TEXT,
|
||||
sequence BIGINT NOT NULL DEFAULT 0,
|
||||
records_committed BIGINT NOT NULL DEFAULT 0,
|
||||
committed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (source_id, partition_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_ingestion_checkpoints_tenant_idx ON opt_ingestion_checkpoints (tenant_id);
|
||||
|
||||
-- ── P068 backfill idempotency ledger ──────────────────────────────────────
|
||||
-- A backfill/replay that re-delivers a record 0pt has already accepted must
|
||||
-- be a no-op, not a duplicate. `payload_hash` distinguishes "same record,
|
||||
-- same content" (skip) from "same record, new content" (an update, emitted).
|
||||
CREATE TABLE IF NOT EXISTS opt_ingestion_ledger (
|
||||
source_id TEXT NOT NULL REFERENCES opt_sources (source_id),
|
||||
record_key TEXT NOT NULL,
|
||||
tenant_id TEXT NOT NULL,
|
||||
payload_hash TEXT NOT NULL,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
delivery_count BIGINT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (source_id, record_key)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_ingestion_ledger_tenant_idx ON opt_ingestion_ledger (tenant_id);
|
||||
|
||||
-- ── P064 quarantine ───────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_quality_quarantine (
|
||||
quarantine_id TEXT PRIMARY KEY,
|
||||
source_id TEXT NOT NULL REFERENCES opt_sources (source_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
record_key TEXT NOT NULL,
|
||||
violations JSONB NOT NULL, -- [{expectation_type, kwargs, detail}]
|
||||
envelope JSONB NOT NULL,
|
||||
quarantined_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_quality_quarantine_source_idx ON opt_quality_quarantine (source_id, quarantined_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS opt_quality_quarantine_tenant_idx ON opt_quality_quarantine (tenant_id);
|
||||
|
||||
-- ── P067 queryable lineage projection ─────────────────────────────────────
|
||||
-- One row per (run, input, output) edge. `edge_id` is deterministic
|
||||
-- (sha256 of the tuple) so a re-emitted idempotent run does not duplicate
|
||||
-- edges -- the same dedup property LineageEmitter relies on in AUD0.
|
||||
CREATE TABLE IF NOT EXISTS opt_lineage_edges (
|
||||
edge_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
capability_id TEXT NOT NULL,
|
||||
input_namespace TEXT NOT NULL,
|
||||
input_name TEXT NOT NULL,
|
||||
input_ref TEXT,
|
||||
output_namespace TEXT NOT NULL,
|
||||
output_name TEXT NOT NULL,
|
||||
output_ref TEXT,
|
||||
event_time TIMESTAMPTZ NOT NULL,
|
||||
correlation_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_lineage_edges_output_idx ON opt_lineage_edges (tenant_id, output_name);
|
||||
CREATE INDEX IF NOT EXISTS opt_lineage_edges_input_idx ON opt_lineage_edges (tenant_id, input_name);
|
||||
CREATE INDEX IF NOT EXISTS opt_lineage_edges_run_idx ON opt_lineage_edges (tenant_id, run_id);
|
||||
@@ -0,0 +1,436 @@
|
||||
-- 0pt analytics domain model (BI-P002/BI-P003/BI-P004).
|
||||
--
|
||||
-- The nineteen canonical entities from BI-P002, one table each, named after
|
||||
-- the entity (src/analytics/domain/entities.ts's ANALYTICS_ENTITY_TABLES maps
|
||||
-- entity -> table, and tests/analytics/analytics-domain-postgres.realinfra.test.ts
|
||||
-- asserts every entry exists here -- a rename or omission is a test failure,
|
||||
-- not silent drift).
|
||||
--
|
||||
-- Every tenant-owned table carries `tenant_id` + `project_id`, and every one
|
||||
-- of them is indexed leading with (tenant_id, project_id) so a tenant-scoped
|
||||
-- read is an index range scan rather than a filter over everyone's rows.
|
||||
--
|
||||
-- IMMUTABILITY (BI-P002: "Use immutable versions for MetricVersion,
|
||||
-- SemanticModel version, DashboardVersion, DatasetVersion") is enforced by
|
||||
-- BEFORE UPDATE/DELETE triggers, not by convention and not by a TypeScript
|
||||
-- `readonly`. A compiler opinion does not survive a psql session; a trigger
|
||||
-- does. `opt_analytics_forbid_mutation()` raises, so an UPDATE against any of
|
||||
-- the four version tables fails loudly.
|
||||
--
|
||||
-- Idempotent throughout (CREATE ... IF NOT EXISTS, CREATE OR REPLACE
|
||||
-- FUNCTION, DROP TRIGGER IF EXISTS before CREATE TRIGGER) so it can be
|
||||
-- replayed onto an already-migrated database -- the same contract
|
||||
-- sql/002_source_registry.sql and sql/007_ingestion_plane.sql keep, and what
|
||||
-- the restore drill (`psql -d opt < sql/001... < ... < sql/008...`) needs.
|
||||
--
|
||||
-- Ordering: standalone. It references no earlier table, so 001-007 are not a
|
||||
-- prerequisite; it is numbered 008 to keep the replay order linear.
|
||||
|
||||
-- ── immutability enforcement ──────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION opt_analytics_forbid_mutation() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'BI-P002: % rows are immutable (attempted %); mint a new version instead',
|
||||
TG_TABLE_NAME, TG_OP
|
||||
USING ERRCODE = 'restrict_violation';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ── 1. AnalyticsDataset ───────────────────────────────────────────────────
|
||||
-- BI-P003 DoD: tier/schema/owner/source/freshness/quality/retention/
|
||||
-- classification are all NOT NULL. An optional declaration is not one.
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dataset (
|
||||
dataset_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
tier TEXT NOT NULL CHECK (tier IN ('RAW','NORMALIZED','CURATED','SERVING')),
|
||||
schema_contract_id TEXT NOT NULL,
|
||||
schema_contract_version TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
source_ref TEXT NOT NULL,
|
||||
freshness_slo_seconds INTEGER NOT NULL CHECK (freshness_slo_seconds > 0),
|
||||
quality_rule_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
retention_days INTEGER NOT NULL CHECK (retention_days >= 0),
|
||||
classification TEXT NOT NULL CHECK (classification IN ('none','pseudonymous','personal','sensitive')),
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','active','deprecated','retired')),
|
||||
raw_access_exception TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
created_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dataset_tenant_idx
|
||||
ON opt_analytics_dataset (tenant_id, project_id, status);
|
||||
|
||||
-- ── 2. DatasetVersion (IMMUTABLE) ─────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dataset_version (
|
||||
dataset_version_id TEXT PRIMARY KEY,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL CHECK (version >= 1),
|
||||
contract_hash TEXT NOT NULL,
|
||||
schema_contract_version TEXT NOT NULL,
|
||||
record_count BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT,
|
||||
UNIQUE (dataset_id, version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dataset_version_tenant_idx
|
||||
ON opt_analytics_dataset_version (tenant_id, project_id, dataset_id, version);
|
||||
DROP TRIGGER IF EXISTS opt_analytics_dataset_version_immutable ON opt_analytics_dataset_version;
|
||||
CREATE TRIGGER opt_analytics_dataset_version_immutable
|
||||
BEFORE UPDATE OR DELETE ON opt_analytics_dataset_version
|
||||
FOR EACH ROW EXECUTE FUNCTION opt_analytics_forbid_mutation();
|
||||
|
||||
-- ── 3. DatasetField ───────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dataset_field (
|
||||
dataset_field_id TEXT PRIMARY KEY,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
dataset_version INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
field_type TEXT NOT NULL CHECK (field_type IN ('string','int64','float64','bool','date','timestamp','decimal','json')),
|
||||
nullable BOOLEAN NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('identifier','dimension','measure','attribute')),
|
||||
primary_identifier BOOLEAN NOT NULL DEFAULT false,
|
||||
classification TEXT NOT NULL DEFAULT 'none' CHECK (classification IN ('none','pseudonymous','personal','sensitive')),
|
||||
description TEXT,
|
||||
UNIQUE (dataset_id, dataset_version, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dataset_field_tenant_idx
|
||||
ON opt_analytics_dataset_field (tenant_id, project_id, dataset_id);
|
||||
|
||||
-- ── 4. DatasetGrant ───────────────────────────────────────────────────────
|
||||
-- allowed_purposes is NOT NULL and CHECKed non-empty: a grant without a
|
||||
-- purpose is a GDPR purpose-limitation hole, so the database refuses it.
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dataset_grant (
|
||||
grant_id TEXT PRIMARY KEY,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
subject_type TEXT NOT NULL CHECK (subject_type IN ('user','role','service','bi_application')),
|
||||
level TEXT NOT NULL CHECK (level IN ('read','query','export','admin')),
|
||||
-- coalesce(...) is load-bearing: array_length('{}', 1) is NULL in Postgres,
|
||||
-- and a CHECK that evaluates to NULL PASSES. Written the obvious way, this
|
||||
-- constraint could never fail -- caught by
|
||||
-- tests/analytics/analytics-domain-postgres.realinfra.test.ts inserting an
|
||||
-- empty array and watching it succeed.
|
||||
allowed_purposes TEXT[] NOT NULL CONSTRAINT opt_analytics_dataset_grant_purposes_nonempty
|
||||
CHECK (coalesce(array_length(allowed_purposes, 1), 0) >= 1),
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dataset_grant_tenant_idx
|
||||
ON opt_analytics_dataset_grant (tenant_id, project_id, dataset_id, subject_id);
|
||||
|
||||
-- ── 5. MetricDefinition ───────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_metric_definition (
|
||||
metric_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
aggregation TEXT NOT NULL CHECK (aggregation IN ('sum','avg','min','max','count','count_distinct','ratio','derived')),
|
||||
measure_field TEXT NOT NULL,
|
||||
denominator_field TEXT,
|
||||
allowed_dimension_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
current_version INTEGER NOT NULL DEFAULT 1,
|
||||
owner_id TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_metric_definition_tenant_idx
|
||||
ON opt_analytics_metric_definition (tenant_id, project_id, dataset_id);
|
||||
|
||||
-- ── 6. MetricVersion (IMMUTABLE) ──────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_metric_version (
|
||||
metric_version_id TEXT PRIMARY KEY,
|
||||
metric_id TEXT NOT NULL REFERENCES opt_analytics_metric_definition (metric_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL CHECK (version >= 1),
|
||||
definition_json TEXT NOT NULL,
|
||||
definition_hash TEXT NOT NULL,
|
||||
change_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT,
|
||||
UNIQUE (metric_id, version),
|
||||
-- BI-P031 governance in the schema: every version after the first must say
|
||||
-- WHY the number changed meaning.
|
||||
CHECK (version = 1 OR (change_reason IS NOT NULL AND length(btrim(change_reason)) > 0))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_metric_version_tenant_idx
|
||||
ON opt_analytics_metric_version (tenant_id, project_id, metric_id, version);
|
||||
DROP TRIGGER IF EXISTS opt_analytics_metric_version_immutable ON opt_analytics_metric_version;
|
||||
CREATE TRIGGER opt_analytics_metric_version_immutable
|
||||
BEFORE UPDATE OR DELETE ON opt_analytics_metric_version
|
||||
FOR EACH ROW EXECUTE FUNCTION opt_analytics_forbid_mutation();
|
||||
|
||||
-- ── 7. DimensionDefinition ────────────────────────────────────────────────
|
||||
-- The allow-list the query compiler resolves caller-supplied dimension names
|
||||
-- through (BI-P011 "dimension identifier injection"). `field_name` is CHECKed
|
||||
-- against a strict identifier pattern in the DATABASE as well as in the
|
||||
-- compiler, because a hostile row here would otherwise be the injection.
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dimension_definition (
|
||||
dimension_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
field_name TEXT NOT NULL CHECK (field_name ~ '^[A-Za-z_][A-Za-z0-9_]{0,62}$'),
|
||||
field_type TEXT NOT NULL CHECK (field_type IN ('string','int64','float64','bool','date','timestamp','decimal','json')),
|
||||
classification TEXT NOT NULL DEFAULT 'none' CHECK (classification IN ('none','pseudonymous','personal','sensitive')),
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, dataset_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dimension_definition_tenant_idx
|
||||
ON opt_analytics_dimension_definition (tenant_id, project_id, dataset_id);
|
||||
|
||||
-- ── 8. SemanticModel (versions IMMUTABLE) ─────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_semantic_model (
|
||||
semantic_model_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
version INTEGER NOT NULL CHECK (version >= 1),
|
||||
model_json TEXT NOT NULL,
|
||||
model_hash TEXT NOT NULL,
|
||||
dataset_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT,
|
||||
UNIQUE (tenant_id, name, version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_semantic_model_tenant_idx
|
||||
ON opt_analytics_semantic_model (tenant_id, project_id, name, version);
|
||||
DROP TRIGGER IF EXISTS opt_analytics_semantic_model_immutable ON opt_analytics_semantic_model;
|
||||
CREATE TRIGGER opt_analytics_semantic_model_immutable
|
||||
BEFORE UPDATE OR DELETE ON opt_analytics_semantic_model
|
||||
FOR EACH ROW EXECUTE FUNCTION opt_analytics_forbid_mutation();
|
||||
|
||||
-- ── 9. AnalyticsQuery ─────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_query (
|
||||
query_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
surface TEXT NOT NULL CHECK (surface IN ('metric','sql','projection')),
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
metric_id TEXT REFERENCES opt_analytics_metric_definition (metric_id),
|
||||
request_json TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_query_tenant_idx
|
||||
ON opt_analytics_query (tenant_id, project_id, dataset_id);
|
||||
|
||||
-- ── 10. QueryExecution ────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_query_execution (
|
||||
execution_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
query_id TEXT REFERENCES opt_analytics_query (query_id),
|
||||
engine_query_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('queued','running','succeeded','failed','cancelled','rejected')),
|
||||
provider TEXT NOT NULL,
|
||||
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
started_at TIMESTAMPTZ,
|
||||
finished_at TIMESTAMPTZ,
|
||||
duration_ms INTEGER,
|
||||
rows_read BIGINT,
|
||||
bytes_read BIGINT,
|
||||
result_rows BIGINT,
|
||||
error_code TEXT,
|
||||
correlation_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_query_execution_tenant_idx
|
||||
ON opt_analytics_query_execution (tenant_id, project_id, submitted_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_query_execution_engine_idx
|
||||
ON opt_analytics_query_execution (engine_query_id);
|
||||
|
||||
-- ── 11. QueryResult ───────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_query_result (
|
||||
result_id TEXT PRIMARY KEY,
|
||||
execution_id TEXT NOT NULL REFERENCES opt_analytics_query_execution (execution_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
encoding TEXT NOT NULL CHECK (encoding IN ('json','arrow_ipc')),
|
||||
row_count BIGINT NOT NULL,
|
||||
artifact_ref TEXT,
|
||||
truncated BOOLEAN NOT NULL DEFAULT false,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_query_result_tenant_idx
|
||||
ON opt_analytics_query_result (tenant_id, project_id, execution_id);
|
||||
|
||||
-- ── 12. DashboardDefinition ───────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dashboard_definition (
|
||||
dashboard_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
current_version INTEGER NOT NULL DEFAULT 1,
|
||||
owner_id TEXT NOT NULL,
|
||||
dataset_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dashboard_definition_tenant_idx
|
||||
ON opt_analytics_dashboard_definition (tenant_id, project_id);
|
||||
|
||||
-- ── 13. DashboardVersion (IMMUTABLE) ──────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dashboard_version (
|
||||
dashboard_version_id TEXT PRIMARY KEY,
|
||||
dashboard_id TEXT NOT NULL REFERENCES opt_analytics_dashboard_definition (dashboard_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL CHECK (version >= 1),
|
||||
layout_json TEXT NOT NULL,
|
||||
layout_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT,
|
||||
UNIQUE (dashboard_id, version)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dashboard_version_tenant_idx
|
||||
ON opt_analytics_dashboard_version (tenant_id, project_id, dashboard_id, version);
|
||||
DROP TRIGGER IF EXISTS opt_analytics_dashboard_version_immutable ON opt_analytics_dashboard_version;
|
||||
CREATE TRIGGER opt_analytics_dashboard_version_immutable
|
||||
BEFORE UPDATE OR DELETE ON opt_analytics_dashboard_version
|
||||
FOR EACH ROW EXECUTE FUNCTION opt_analytics_forbid_mutation();
|
||||
|
||||
-- ── 14. ReportDefinition ──────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_report_definition (
|
||||
report_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
dashboard_id TEXT REFERENCES opt_analytics_dashboard_definition (dashboard_id),
|
||||
query_id TEXT REFERENCES opt_analytics_query (query_id),
|
||||
format TEXT NOT NULL CHECK (format IN ('pdf','csv','xlsx','parquet','arrow_ipc')),
|
||||
schedule_cron TEXT,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
CHECK (dashboard_id IS NOT NULL OR query_id IS NOT NULL)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_report_definition_tenant_idx
|
||||
ON opt_analytics_report_definition (tenant_id, project_id);
|
||||
|
||||
-- ── 15. ReportRun ─────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_report_run (
|
||||
report_run_id TEXT PRIMARY KEY,
|
||||
report_id TEXT NOT NULL REFERENCES opt_analytics_report_definition (report_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('queued','running','succeeded','failed','cancelled','rejected')),
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
artifact_ref TEXT,
|
||||
error_code TEXT,
|
||||
correlation_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_report_run_tenant_idx
|
||||
ON opt_analytics_report_run (tenant_id, project_id, report_id, started_at DESC);
|
||||
|
||||
-- ── 16. DataQualityRule ───────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_data_quality_rule (
|
||||
rule_id TEXT PRIMARY KEY,
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('not_null','unique','accepted_values','range','freshness','row_count','referential','custom')),
|
||||
field_name TEXT,
|
||||
params_json TEXT NOT NULL DEFAULT '{}',
|
||||
severity TEXT NOT NULL CHECK (severity IN ('warn','block')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_data_quality_rule_tenant_idx
|
||||
ON opt_analytics_data_quality_rule (tenant_id, project_id, dataset_id);
|
||||
|
||||
-- ── 17. DataQualityRun ────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_data_quality_run (
|
||||
quality_run_id TEXT PRIMARY KEY,
|
||||
rule_id TEXT NOT NULL REFERENCES opt_analytics_data_quality_rule (rule_id),
|
||||
dataset_id TEXT NOT NULL REFERENCES opt_analytics_dataset (dataset_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
dataset_version INTEGER,
|
||||
passed BOOLEAN NOT NULL,
|
||||
violation_count BIGINT NOT NULL DEFAULT 0,
|
||||
evaluated_rows BIGINT NOT NULL DEFAULT 0,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
correlation_id TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_data_quality_run_tenant_idx
|
||||
ON opt_analytics_data_quality_run (tenant_id, project_id, dataset_id, started_at DESC);
|
||||
|
||||
-- ── 18. LineageReference ──────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_lineage_reference (
|
||||
lineage_reference_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL,
|
||||
job_name TEXT NOT NULL,
|
||||
direction TEXT NOT NULL CHECK (direction IN ('input','output')),
|
||||
entity_type TEXT NOT NULL CHECK (entity_type IN ('dataset','metric','dashboard','report')),
|
||||
entity_id TEXT NOT NULL,
|
||||
counterpart_ref TEXT NOT NULL,
|
||||
observed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_lineage_reference_tenant_idx
|
||||
ON opt_analytics_lineage_reference (tenant_id, project_id, entity_type, entity_id);
|
||||
|
||||
-- ── 19. BIApplicationBinding ──────────────────────────────────────────────
|
||||
-- NORMATIVE: there is NO credential column here, and there must never be one.
|
||||
-- SIAX/0pt owns the analytics engine; 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.
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_bi_application_binding (
|
||||
binding_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('superset','evidence','des1gn','notebook','custom')),
|
||||
name TEXT NOT NULL,
|
||||
principal_id TEXT NOT NULL,
|
||||
dataset_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
metric_ids TEXT[] NOT NULL DEFAULT '{}',
|
||||
-- Same NULL-CHECK trap as opt_analytics_dataset_grant; see the note there.
|
||||
allowed_purposes TEXT[] NOT NULL CONSTRAINT opt_analytics_bi_app_binding_purposes_nonempty
|
||||
CHECK (coalesce(array_length(allowed_purposes, 1), 0) >= 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by TEXT,
|
||||
UNIQUE (tenant_id, name)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_bi_application_binding_tenant_idx
|
||||
ON opt_analytics_bi_application_binding (tenant_id, project_id, principal_id);
|
||||
|
||||
-- ── constraint upgrade path ───────────────────────────────────────────────
|
||||
-- CREATE TABLE IF NOT EXISTS does nothing to a table that already exists, so
|
||||
-- a database that received an earlier copy of this file would keep the old
|
||||
-- (NULL-passing) purpose checks. These statements bring it forward and are
|
||||
-- no-ops on a fresh database.
|
||||
ALTER TABLE opt_analytics_dataset_grant
|
||||
DROP CONSTRAINT IF EXISTS opt_analytics_dataset_grant_purposes_nonempty;
|
||||
ALTER TABLE opt_analytics_dataset_grant
|
||||
ADD CONSTRAINT opt_analytics_dataset_grant_purposes_nonempty
|
||||
CHECK (coalesce(array_length(allowed_purposes, 1), 0) >= 1);
|
||||
|
||||
ALTER TABLE opt_analytics_bi_application_binding
|
||||
DROP CONSTRAINT IF EXISTS opt_analytics_bi_app_binding_purposes_nonempty;
|
||||
ALTER TABLE opt_analytics_bi_application_binding
|
||||
ADD CONSTRAINT opt_analytics_bi_app_binding_purposes_nonempty
|
||||
CHECK (coalesce(array_length(allowed_purposes, 1), 0) >= 1);
|
||||
@@ -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 = '<table>'::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();
|
||||
@@ -0,0 +1,129 @@
|
||||
-- 0pt analytics: dashboard releases (BI-P061 — DASHBOARD VERSIONING).
|
||||
--
|
||||
-- Extends sql/008. Additive and idempotent (CREATE TABLE IF NOT EXISTS,
|
||||
-- CREATE OR REPLACE FUNCTION, DROP TRIGGER IF EXISTS before CREATE TRIGGER),
|
||||
-- so it replays onto an already-migrated database like 009 does.
|
||||
--
|
||||
-- Ordering: requires sql/008 (it references opt_analytics_dashboard_version).
|
||||
--
|
||||
-- WHY A SEPARATE TABLE RATHER THAN A `state` COLUMN ON THE VERSION.
|
||||
-- sql/008 puts a BEFORE UPDATE OR DELETE trigger on
|
||||
-- opt_analytics_dashboard_version, because BI-P002 lists DashboardVersion as
|
||||
-- immutable. A lifecycle column on that row would need UPDATEs, so adding one
|
||||
-- would mean weakening the immutability everything else depends on. Instead
|
||||
-- the frozen DOCUMENT stays immutable in the version table, and the RELEASE —
|
||||
-- the thing that legitimately moves DRAFT -> PUBLISHED -> SUPERSEDED — lives
|
||||
-- here with its own, much narrower, mutation rule:
|
||||
--
|
||||
-- * `state` may only advance draft -> published -> superseded. Any other
|
||||
-- transition RAISEs, including published -> draft ("un-publishing" is how
|
||||
-- a rollback gets faked without leaving a trace) and superseded -> *.
|
||||
-- * Every content column is frozen on UPDATE. A release may change state and
|
||||
-- nothing else; repointing a published release at a different version is
|
||||
-- the exact edit that would make "immutable release" a lie.
|
||||
-- * DELETE is refused outright. Release history is a ledger; a rollback is
|
||||
-- an entry in it, never the removal of one.
|
||||
--
|
||||
-- THE INVARIANT THAT NEEDS THE DATABASE: at most ONE published release per
|
||||
-- dashboard. An application check reads, decides, then writes, and two
|
||||
-- requests that interleave between the read and the write both decide "yes".
|
||||
-- The partial unique index below turns that race into a constraint violation
|
||||
-- for the loser. tests/analytics/dashboard-release-postgres.realinfra.test.ts
|
||||
-- runs the race with the index DROPPED first and shows two published releases
|
||||
-- for one dashboard, so the index is known to be what prevents it.
|
||||
|
||||
-- ── the release ledger ────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS opt_analytics_dashboard_release (
|
||||
release_id TEXT PRIMARY KEY,
|
||||
dashboard_id TEXT NOT NULL REFERENCES opt_analytics_dashboard_definition (dashboard_id),
|
||||
dashboard_version_id TEXT NOT NULL REFERENCES opt_analytics_dashboard_version (dashboard_version_id),
|
||||
tenant_id TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
version INTEGER NOT NULL CHECK (version >= 1),
|
||||
state TEXT NOT NULL CHECK (state IN ('draft', 'published', 'superseded')),
|
||||
-- The BI-P060 canonical hash of the released document. Copied here so a
|
||||
-- rollback can be VERIFIED (the new release must carry the hash of the
|
||||
-- version it restores) without re-reading and re-hashing the document.
|
||||
document_hash TEXT NOT NULL CHECK (document_hash ~ '^[0-9a-f]{64}$'),
|
||||
-- Set when this release restores an earlier version. It is a NEW release of
|
||||
-- a NEW version, never a resurrected old row -- that is what keeps the
|
||||
-- history linear and the audit answerable ("what was live at 14:05?").
|
||||
rollback_of_version INTEGER CHECK (rollback_of_version >= 1),
|
||||
created_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
published_at TIMESTAMPTZ,
|
||||
superseded_at TIMESTAMPTZ,
|
||||
superseded_by_release_id TEXT,
|
||||
UNIQUE (dashboard_id, version),
|
||||
-- A state's timestamps must match the state. A 'published' row with no
|
||||
-- published_at is the shape that makes an SLO or an audit answer wrong.
|
||||
CONSTRAINT opt_analytics_dashboard_release_timestamps CHECK (
|
||||
(state = 'draft' AND published_at IS NULL AND superseded_at IS NULL)
|
||||
OR (state = 'published' AND published_at IS NOT NULL AND superseded_at IS NULL)
|
||||
OR (state = 'superseded' AND published_at IS NOT NULL AND superseded_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS opt_analytics_dashboard_release_tenant_idx
|
||||
ON opt_analytics_dashboard_release (tenant_id, project_id, dashboard_id, version);
|
||||
|
||||
-- At most one PUBLISHED release per dashboard. This is the concurrency
|
||||
-- guarantee; the application's check is only an early, friendlier error.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS opt_analytics_dashboard_release_one_published
|
||||
ON opt_analytics_dashboard_release (dashboard_id)
|
||||
WHERE state = 'published';
|
||||
|
||||
-- At most one open DRAFT per dashboard. Two drafts race to publish and the
|
||||
-- loser's edits vanish into a superseded release nobody asked for.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS opt_analytics_dashboard_release_one_draft
|
||||
ON opt_analytics_dashboard_release (dashboard_id)
|
||||
WHERE state = 'draft';
|
||||
|
||||
-- ── transition enforcement ────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION opt_analytics_dashboard_release_guard() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RAISE EXCEPTION
|
||||
'BI-P061: dashboard releases are a ledger; delete is refused (release %)', OLD.release_id
|
||||
USING ERRCODE = 'restrict_violation';
|
||||
END IF;
|
||||
|
||||
-- Content is frozen for the life of the release.
|
||||
IF NEW.dashboard_id IS DISTINCT FROM OLD.dashboard_id
|
||||
OR NEW.dashboard_version_id IS DISTINCT FROM OLD.dashboard_version_id
|
||||
OR NEW.tenant_id IS DISTINCT FROM OLD.tenant_id
|
||||
OR NEW.project_id IS DISTINCT FROM OLD.project_id
|
||||
OR NEW.version IS DISTINCT FROM OLD.version
|
||||
OR NEW.document_hash IS DISTINCT FROM OLD.document_hash
|
||||
OR NEW.rollback_of_version IS DISTINCT FROM OLD.rollback_of_version
|
||||
OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
|
||||
RAISE EXCEPTION
|
||||
'BI-P061: a dashboard release is immutable apart from its state (release %)', OLD.release_id
|
||||
USING ERRCODE = 'restrict_violation';
|
||||
END IF;
|
||||
|
||||
IF NEW.state IS DISTINCT FROM OLD.state THEN
|
||||
IF NOT ((OLD.state = 'draft' AND NEW.state = 'published')
|
||||
OR (OLD.state = 'published' AND NEW.state = 'superseded')) THEN
|
||||
RAISE EXCEPTION
|
||||
'BI-P061: illegal dashboard release transition % -> % (release %)',
|
||||
OLD.state, NEW.state, OLD.release_id
|
||||
USING ERRCODE = 'restrict_violation';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- A published timestamp, once set, is history.
|
||||
IF OLD.published_at IS NOT NULL AND NEW.published_at IS DISTINCT FROM OLD.published_at THEN
|
||||
RAISE EXCEPTION
|
||||
'BI-P061: published_at is history and cannot be rewritten (release %)', OLD.release_id
|
||||
USING ERRCODE = 'restrict_violation';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS opt_analytics_dashboard_release_guard ON opt_analytics_dashboard_release;
|
||||
CREATE TRIGGER opt_analytics_dashboard_release_guard
|
||||
BEFORE UPDATE OR DELETE ON opt_analytics_dashboard_release
|
||||
FOR EACH ROW EXECUTE FUNCTION opt_analytics_dashboard_release_guard();
|
||||
@@ -0,0 +1,39 @@
|
||||
-- 0pt durable AUD0 evidence outbox (§70)
|
||||
-- AUD0 remains canonical evidence; this is only a durable producer queue.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_aud0_evidence_outbox (
|
||||
outbox_id text PRIMARY KEY,
|
||||
tenant_id text NOT NULL,
|
||||
app_id text NOT NULL DEFAULT '0pt',
|
||||
evidence_kind text NOT NULL CHECK (evidence_kind IN ('event', 'openlineage')),
|
||||
idempotency_key text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'delivered', 'dead')),
|
||||
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
||||
leased_by text,
|
||||
leased_until timestamptz,
|
||||
last_error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
delivered_at timestamptz,
|
||||
dead_at timestamptz,
|
||||
CONSTRAINT opt_aud0_evidence_outbox_idempotency
|
||||
UNIQUE (tenant_id, evidence_kind, idempotency_key),
|
||||
CONSTRAINT opt_aud0_evidence_outbox_delivery_consistency CHECK (
|
||||
(status = 'delivered' AND delivered_at IS NOT NULL AND dead_at IS NULL)
|
||||
OR (status = 'dead' AND dead_at IS NOT NULL AND delivered_at IS NULL)
|
||||
OR (status = 'pending' AND delivered_at IS NULL AND dead_at IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS opt_aud0_evidence_outbox_pending_idx
|
||||
ON opt_aud0_evidence_outbox (next_attempt_at, created_at)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS opt_aud0_evidence_outbox_tenant_idx
|
||||
ON opt_aud0_evidence_outbox (tenant_id, created_at DESC);
|
||||
|
||||
COMMENT ON TABLE opt_aud0_evidence_outbox IS
|
||||
'Durable local delivery queue for 0pt -> AUD0 evidence. Not canonical evidence.';
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 0pt payment-order ownership, ADR-001.
|
||||
-- Apply after 001_init.sql and before deploying tenant-scoped order routes.
|
||||
-- Legacy orders remain NULL-owned and cannot be read or paid by a buyer API.
|
||||
-- Do not backfill from client-supplied config or guessed order IDs.
|
||||
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS tenant_id text;
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS buyer_subject text;
|
||||
|
||||
ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_ownership_pair_ck;
|
||||
ALTER TABLE orders ADD CONSTRAINT orders_ownership_pair_ck
|
||||
CHECK ((tenant_id IS NULL AND buyer_subject IS NULL)
|
||||
OR (tenant_id IS NOT NULL AND length(tenant_id) > 0
|
||||
AND buyer_subject IS NOT NULL AND length(buyer_subject) > 0));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS orders_tenant_created_idx
|
||||
ON orders (tenant_id, created_at DESC)
|
||||
WHERE tenant_id IS NOT NULL;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Durable canonical metadata for 0pt dataset versions.
|
||||
-- Bytes remain canonical ST0RE artifacts referenced by artifact_refs.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_dataset_versions (
|
||||
dataset_id text NOT NULL REFERENCES opt_datasets(dataset_id) ON DELETE RESTRICT,
|
||||
version integer NOT NULL CHECK (version > 0),
|
||||
tenant_id text NOT NULL,
|
||||
project_id text NOT NULL,
|
||||
artifact_refs text[] NOT NULL CHECK (cardinality(artifact_refs) > 0),
|
||||
schema_version text NOT NULL,
|
||||
record_count bigint CHECK (record_count IS NULL OR record_count >= 0),
|
||||
description text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by text NOT NULL,
|
||||
correlation_id text,
|
||||
idempotency_key text,
|
||||
PRIMARY KEY (dataset_id, version)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS opt_dataset_versions_idempotency_idx
|
||||
ON opt_dataset_versions (tenant_id, idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS opt_dataset_versions_tenant_dataset_idx
|
||||
ON opt_dataset_versions (tenant_id, dataset_id, version DESC);
|
||||
|
||||
COMMENT ON TABLE opt_dataset_versions IS
|
||||
'Immutable 0pt dataset-version metadata. Content bytes remain in ST0RE artifacts.';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Canonical 0pt search-index definitions. OpenSearch itself remains a
|
||||
-- rebuildable projection; this table stores only the logical schema needed to
|
||||
-- rebuild/query that projection after restart.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_search_index_definitions (
|
||||
index_name text NOT NULL,
|
||||
version integer NOT NULL CHECK (version > 0),
|
||||
fields jsonb NOT NULL,
|
||||
default_search_fields text[] NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_by text,
|
||||
PRIMARY KEY (index_name, version),
|
||||
CONSTRAINT opt_search_index_definitions_fields_object
|
||||
CHECK (jsonb_typeof(fields) = 'object')
|
||||
);
|
||||
|
||||
COMMENT ON TABLE opt_search_index_definitions IS
|
||||
'Versioned logical 0pt search schemas. OpenSearch indices remain disposable projections.';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 014: subscriptions.created_at — additiv.
|
||||
-- lib/subscriptions-store.ts SELECT:ar `created_at` men sql/001_init.sql definierade
|
||||
-- aldrig kolumnen, så GET /api/subscriptions (200-kontraktet) bröt mot Postgres
|
||||
-- (42703 errorMissingColumn, mätt live 2026-09-22 på 0pt.app). Idempotent; applicerad
|
||||
-- live på 0pt-edge-postgres (xh70k0qa0cyju840zsvsgoze) samma pass.
|
||||
ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS created_at timestamptz DEFAULT now();
|
||||
@@ -0,0 +1,64 @@
|
||||
-- 015_order_status_check.sql — Kanonisk orderstatus (ADR-0002, C3 2026-09-29)
|
||||
--
|
||||
-- Guardar `orders.status` (fri text sedan sql/001_init.sql line ~92) mot den
|
||||
-- kanoniska unionen i lib/order-flow.ts (ORDER_STATUSES). Denna fil KÖRS EJ
|
||||
-- automatiskt av deploy — Simon kör den manuellt mot prod efter harvest.
|
||||
--
|
||||
-- KÖRORDNING (DoD §17 backup-städ, §33 migreringsordning, §35 restore-drill):
|
||||
-- 1. BACKUP FÖRST: pg_dump "$DATABASE_URL" -t orders -Fc -f orders-pre-015.dump
|
||||
-- (och verifiera att restore-drillen har körts nyligen — §35).
|
||||
-- 2. HARVEST-PREFLIGHT (läs-only, körs före fas A):
|
||||
-- SELECT status, COUNT(*) FROM orders GROUP BY status ORDER BY 2 DESC;
|
||||
-- Alla rader MÅSTE ligga i listan nedan. Finns okända statusar:
|
||||
-- AVBRYT — mappa dem först (UPDATE ... WHERE status = '<okänd>') i en
|
||||
-- egen, granskad migrering; kör aldrig fas A med okända rader kvar.
|
||||
-- 3. FAS A ( TRANSACTIONEN NEDAN — ADD CONSTRAINT ... NOT VALID ):
|
||||
-- tar bara ett kort ACCESS EXCLUSIVE-lås vid själva katalogändringen,
|
||||
-- validerar INTE befintliga rader, blockerar inte skrivningar längre tid.
|
||||
-- 4. FAS B (SEPARAT session/transaktion — VALIDATE CONSTRAINT):
|
||||
-- BEGIN; SET lock_timeout = '5s';
|
||||
-- ALTER TABLE orders VALIDATE CONSTRAINT order_status_check;
|
||||
-- COMMIT;
|
||||
-- VALIDATE tar SHARE UPDATE EXCLUSIVE (skrivningar tillåtna); med
|
||||
-- lock_timeout stänger vi ute långa låsköer. Misslyckas fas B: retry.
|
||||
--
|
||||
-- Listan är kod-SSOT: håll synkroniserad med ORDER_STATUSES i lib/order-flow.ts.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Preflight-guard: fas A nekar om okända statusar finns (fail-closed).
|
||||
-- (SELECT ... FOR UPDATE-stil låsning behövs ej — NOT VALID-constrainten
|
||||
-- läser inga rader; vi vill bara neka körning vid dirt-data.)
|
||||
DO $preflight$
|
||||
DECLARE
|
||||
unknown_rows bigint;
|
||||
BEGIN
|
||||
SELECT COUNT(*) INTO unknown_rows
|
||||
FROM orders
|
||||
WHERE status NOT IN ('draft', 'quoted', 'in_production', 'review', 'accepted', 'delivered', 'cancelled');
|
||||
IF unknown_rows > 0 THEN
|
||||
RAISE EXCEPTION
|
||||
'order_status_check preflight: % rader med status utanför den kanoniska unionen — kör harvest (SELECT status, COUNT(*) FROM orders GROUP BY status), mappa okända statusar, KÖR EJ fas A', unknown_rows;
|
||||
END IF;
|
||||
END
|
||||
$preflight$;
|
||||
|
||||
-- Rapportera (loggar) okända statusar om preflighten ändå passerar tom:
|
||||
SELECT status, COUNT(*) AS n
|
||||
FROM orders
|
||||
WHERE status NOT IN ('draft', 'quoted', 'in_production', 'review', 'accepted', 'delivered', 'cancelled')
|
||||
GROUP BY status;
|
||||
-- (0 rader = grönt; >0 rader = ovanstående DO-block har redan avbrutit.)
|
||||
|
||||
ALTER TABLE orders
|
||||
ADD CONSTRAINT order_status_check
|
||||
CHECK (status IN ('draft', 'quoted', 'in_production', 'review', 'accepted', 'delivered', 'cancelled'))
|
||||
NOT VALID;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- FAS B körs i EGEN session (se körordning steg 4) — kommenterad här för att
|
||||
-- den ALDRIG ska ligga i samma transaktion som fas A:
|
||||
-- BEGIN; SET lock_timeout = '5s';
|
||||
-- ALTER TABLE orders VALIDATE CONSTRAINT order_status_check;
|
||||
-- COMMIT;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- 016: order notes — persisted collaboration state för admin-orders
|
||||
-- (@siax/collaboration-kernelns första produktkonsument, spår C).
|
||||
-- Varje note är en kernel-CollaborationOperation: `clock` är Lamport-klockan,
|
||||
-- `version` är optimistisk samtidighetskontroll (stale skrivning -> HTTP 409).
|
||||
CREATE TABLE IF NOT EXISTS order_notes (
|
||||
note_id TEXT PRIMARY KEY,
|
||||
order_id TEXT NOT NULL,
|
||||
tenant_id TEXT,
|
||||
author_id TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
clock BIGINT NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS order_notes_order_idx ON order_notes(order_id);
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Internal credit ledger foundation; no public payment or credit-grant route.
|
||||
CREATE TABLE IF NOT EXISTS opt_credit_accounts (
|
||||
tenant_id text PRIMARY KEY CHECK (length(tenant_id) > 0),
|
||||
available bigint NOT NULL DEFAULT 0 CHECK (available >= 0 AND available <= 9007199254740991),
|
||||
reserved bigint NOT NULL DEFAULT 0 CHECK (reserved >= 0 AND reserved <= 9007199254740991)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_credit_reservations (
|
||||
tenant_id text NOT NULL REFERENCES opt_credit_accounts(tenant_id),
|
||||
reservation_id text NOT NULL CHECK (length(reservation_id) > 0),
|
||||
amount bigint NOT NULL CHECK (amount > 0 AND amount <= 9007199254740991),
|
||||
status text NOT NULL CHECK (status IN ('reserved', 'settled', 'released')),
|
||||
settled_amount bigint CHECK (settled_amount >= 0 AND settled_amount <= amount),
|
||||
PRIMARY KEY (tenant_id, reservation_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_credit_events (
|
||||
event_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
tenant_id text NOT NULL REFERENCES opt_credit_accounts(tenant_id),
|
||||
idempotency_key text NOT NULL CHECK (length(idempotency_key) > 0),
|
||||
kind text NOT NULL CHECK (kind IN ('verified_payment', 'reserve', 'settle', 'release')),
|
||||
amount bigint NOT NULL CHECK (amount >= 0 AND amount <= 9007199254740991),
|
||||
reference text NOT NULL CHECK (length(reference) > 0),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, idempotency_key)
|
||||
);
|
||||
-- A single external payment cannot fund multiple tenants or grants.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS opt_credit_verified_payment_reference
|
||||
ON opt_credit_events(reference) WHERE kind = 'verified_payment';
|
||||
|
||||
-- Server-owned price snapshot. Never read price or credits from PAY0 metadata.
|
||||
CREATE TABLE IF NOT EXISTS opt_credit_purchases (
|
||||
tenant_id text NOT NULL CHECK (length(tenant_id) > 0),
|
||||
purchase_id text NOT NULL CHECK (length(purchase_id) > 0),
|
||||
checkout_session_id text NOT NULL UNIQUE CHECK (length(checkout_session_id) > 0),
|
||||
amount_minor bigint NOT NULL CHECK (amount_minor > 0 AND amount_minor <= 9007199254740991),
|
||||
currency text NOT NULL CHECK (currency ~ '^[A-Z]{3}$'),
|
||||
credits bigint NOT NULL CHECK (credits > 0 AND credits <= 9007199254740991),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, purchase_id)
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Internal coordination only: raw bytes remain in the document storage service.
|
||||
CREATE TABLE opt_document_upload_attempts (
|
||||
tenant_id text NOT NULL CHECK (length(tenant_id) BETWEEN 1 AND 256),
|
||||
project_id text NOT NULL CHECK (length(project_id) BETWEEN 1 AND 256),
|
||||
actor_id text NOT NULL CHECK (length(actor_id) BETWEEN 1 AND 256),
|
||||
attempt_id text NOT NULL CHECK (length(attempt_id) BETWEEN 1 AND 256),
|
||||
source_sha256 text NOT NULL CHECK (source_sha256 ~ '^[0-9a-f]{64}$'),
|
||||
source_size_bytes bigint NOT NULL CHECK (source_size_bytes > 0 AND source_size_bytes <= 9007199254740991),
|
||||
request_fingerprint text NOT NULL CHECK (request_fingerprint ~ '^[0-9a-f]{64}$'),
|
||||
state text NOT NULL CHECK (state IN ('prepared', 'dispatch_started')),
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
PRIMARY KEY (tenant_id, attempt_id)
|
||||
);
|
||||
ALTER TABLE opt_document_upload_attempts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE opt_document_upload_attempts FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY opt_document_upload_attempt_scope ON opt_document_upload_attempts
|
||||
USING (tenant_id = current_setting('app.opt_tenant_id', true)
|
||||
AND project_id = current_setting('app.opt_project_id', true)
|
||||
AND actor_id = current_setting('app.opt_actor_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.opt_tenant_id', true)
|
||||
AND project_id = current_setting('app.opt_project_id', true)
|
||||
AND actor_id = current_setting('app.opt_actor_id', true));
|
||||
@@ -0,0 +1,25 @@
|
||||
-- The GUC is transaction-local and supplied only by trusted server code.
|
||||
-- RLS is a defense against missing tenant predicates, not authentication.
|
||||
ALTER TABLE opt_credit_accounts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE opt_credit_accounts FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY opt_credit_accounts_tenant ON opt_credit_accounts
|
||||
USING (tenant_id = current_setting('app.opt_credit_tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.opt_credit_tenant_id', true));
|
||||
|
||||
ALTER TABLE opt_credit_reservations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE opt_credit_reservations FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY opt_credit_reservations_tenant ON opt_credit_reservations
|
||||
USING (tenant_id = current_setting('app.opt_credit_tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.opt_credit_tenant_id', true));
|
||||
|
||||
ALTER TABLE opt_credit_events ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE opt_credit_events FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY opt_credit_events_tenant ON opt_credit_events
|
||||
USING (tenant_id = current_setting('app.opt_credit_tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.opt_credit_tenant_id', true));
|
||||
|
||||
ALTER TABLE opt_credit_purchases ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE opt_credit_purchases FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY opt_credit_purchases_tenant ON opt_credit_purchases
|
||||
USING (tenant_id = current_setting('app.opt_credit_tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.opt_credit_tenant_id', true));
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Trial / grant credits: a server-owned, idempotent balance grant that is not
|
||||
-- a payment. Extends the existing kind constraint; safe to re-run.
|
||||
DO $$
|
||||
DECLARE cname text;
|
||||
BEGIN
|
||||
SELECT conname INTO cname
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = 'opt_credit_events'::regclass
|
||||
AND contype = 'c'
|
||||
AND pg_get_constraintdef(oid) LIKE '%kind%';
|
||||
IF cname IS NOT NULL THEN
|
||||
EXECUTE format('ALTER TABLE opt_credit_events DROP CONSTRAINT %I', cname);
|
||||
END IF;
|
||||
ALTER TABLE opt_credit_events ADD CONSTRAINT opt_credit_events_kind_check
|
||||
CHECK (kind IN ('verified_payment', 'grant', 'reserve', 'settle', 'release'));
|
||||
END $$;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Persist the verified DOC0 receipt on the durable upload attempt so an
|
||||
-- idempotent retry returns the same 201 instead of "outcome unknown".
|
||||
-- Additive and re-runnable.
|
||||
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS document_id text;
|
||||
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS version_id text;
|
||||
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS receipt_checksum text;
|
||||
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS pipeline_stage text;
|
||||
ALTER TABLE opt_document_upload_attempts ADD COLUMN IF NOT EXISTS verified_at timestamptz;
|
||||
@@ -0,0 +1,102 @@
|
||||
-- 022_canonical_facts.sql — 0pt canonical structured observations (X-800 / Scrape).
|
||||
--
|
||||
-- 0pt owns normalized OBSERVED facts (what a source said, when, from which
|
||||
-- artifact). L0RE owns epistemic propositions/assertions and projections; it
|
||||
-- must consume these rows through this API/contract, never by writing here.
|
||||
--
|
||||
-- Conflict preservation is structural: the unique key includes source_ref and
|
||||
-- observed_at, so two sources disagreeing about the same predicate are two
|
||||
-- rows, never an overwrite. `status` records observed/supported/disputed/
|
||||
-- retracted; reconciliation is a separate, explicit decision.
|
||||
--
|
||||
-- Tenant isolation: RLS is FORCEd and keyed on a transaction-local GUC that
|
||||
-- only trusted server code sets (same pattern as 019_document_credit_rls.sql).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_canonical_entities (
|
||||
entity_id UUID PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL, -- company|person|web_page|product|...
|
||||
identity_namespace TEXT NOT NULL, -- url|orgnr|lei|gtin|...
|
||||
identity_value TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, entity_type, identity_namespace, identity_value)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_canonical_entities_tenant_type
|
||||
ON opt_canonical_entities (tenant_id, entity_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_canonical_facts (
|
||||
fact_id UUID PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
entity_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
|
||||
predicate TEXT NOT NULL, -- e.g. web.title, product.price
|
||||
value_json JSONB NOT NULL,
|
||||
value_type TEXT NOT NULL, -- string|number|boolean|date|json
|
||||
value_unit TEXT,
|
||||
source_ref TEXT NOT NULL,
|
||||
source_record_id TEXT,
|
||||
st0re_artifact_id TEXT,
|
||||
aud0_evidence_ref TEXT,
|
||||
run_ref TEXT,
|
||||
extractor_version TEXT NOT NULL,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
valid_from TIMESTAMPTZ,
|
||||
valid_to TIMESTAMPTZ,
|
||||
confidence NUMERIC CHECK (confidence BETWEEN 0 AND 1),
|
||||
quality_status TEXT NOT NULL DEFAULT 'unvalidated', -- passed|warned|failed|unvalidated
|
||||
schema_version TEXT NOT NULL DEFAULT '0pt.canonical_fact/v1',
|
||||
status TEXT NOT NULL DEFAULT 'observed'
|
||||
CHECK (status IN ('observed','supported','disputed','retracted')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, entity_id, predicate, source_ref, observed_at)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_canonical_facts_entity
|
||||
ON opt_canonical_facts (tenant_id, entity_id, predicate);
|
||||
CREATE INDEX IF NOT EXISTS idx_canonical_facts_predicate
|
||||
ON opt_canonical_facts (tenant_id, predicate, observed_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opt_canonical_relations (
|
||||
relation_id UUID PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
subject_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
|
||||
predicate TEXT NOT NULL, -- LINKS_TO|SOLD_BY|USES_SOFTWARE|...
|
||||
object_id UUID NOT NULL REFERENCES opt_canonical_entities (entity_id),
|
||||
attributes_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
source_ref TEXT NOT NULL,
|
||||
st0re_artifact_id TEXT,
|
||||
aud0_evidence_ref TEXT,
|
||||
run_ref TEXT,
|
||||
extractor_version TEXT NOT NULL,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
valid_from TIMESTAMPTZ,
|
||||
valid_to TIMESTAMPTZ,
|
||||
confidence NUMERIC CHECK (confidence BETWEEN 0 AND 1),
|
||||
schema_version TEXT NOT NULL DEFAULT '0pt.canonical_relation/v1',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (tenant_id, subject_id, predicate, object_id, source_ref, observed_at)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_canonical_relations_subject
|
||||
ON opt_canonical_relations (tenant_id, subject_id, predicate);
|
||||
CREATE INDEX IF NOT EXISTS idx_canonical_relations_object
|
||||
ON opt_canonical_relations (tenant_id, object_id, predicate);
|
||||
|
||||
ALTER TABLE opt_canonical_entities ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE opt_canonical_entities FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS opt_canonical_entities_tenant ON opt_canonical_entities;
|
||||
CREATE POLICY opt_canonical_entities_tenant ON opt_canonical_entities
|
||||
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
|
||||
|
||||
ALTER TABLE opt_canonical_facts ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE opt_canonical_facts FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS opt_canonical_facts_tenant ON opt_canonical_facts;
|
||||
CREATE POLICY opt_canonical_facts_tenant ON opt_canonical_facts
|
||||
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
|
||||
|
||||
ALTER TABLE opt_canonical_relations ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE opt_canonical_relations FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS opt_canonical_relations_tenant ON opt_canonical_relations;
|
||||
CREATE POLICY opt_canonical_relations_tenant ON opt_canonical_relations
|
||||
USING (tenant_id = current_setting('app.opt_canonical_tenant_id', true))
|
||||
WITH CHECK (tenant_id = current_setting('app.opt_canonical_tenant_id', true));
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 023_canonical_grants.sql — app-role privileges for 022 canonical tables.
|
||||
--
|
||||
-- Tables created by a manual psql migration are owned by the migrating role
|
||||
-- (0pt_edge); the running application connects as 0pt_app. Existing 0pt tables
|
||||
-- carry explicit grants rather than default ACLs, so the canonical tables need
|
||||
-- the same treatment. RLS still applies to 0pt_app (FORCE is on); this only
|
||||
-- grants the statement privileges, not row visibility.
|
||||
--
|
||||
-- Idempotent and role-guarded: no-op in environments without the app role.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '0pt_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE
|
||||
ON opt_canonical_entities, opt_canonical_facts, opt_canonical_relations
|
||||
TO "0pt_app";
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
Reference in New Issue
Block a user