fix(recovery): restore source removed by production documentation merge

This commit is contained in:
2026-11-10 03:42:10 +01:00
parent 585bdd4b44
commit 3d2fc6f913
957 changed files with 115754 additions and 0 deletions
+137
View File
@@ -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()
);
+105
View File
@@ -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);
+41
View File
@@ -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);
+62
View File
@@ -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);
+24
View File
@@ -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;
+162
View File
@@ -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);
+436
View File
@@ -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();
+129
View File
@@ -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();
+39
View File
@@ -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.';
+17
View File
@@ -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;
+28
View File
@@ -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.';
+18
View File
@@ -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.';
+6
View File
@@ -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();
+64
View File
@@ -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;
+16
View File
@@ -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);
+41
View File
@@ -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)
);
+23
View File
@@ -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));
+25
View File
@@ -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));
+16
View File
@@ -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 $$;
+8
View File
@@ -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;
+102
View File
@@ -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));
+19
View File
@@ -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
$$;