vep/schema/init.sql
2026-08-07 19:26:49 +00:00

92 lines
4.3 KiB
SQL

-- VEP P1 schema: multi-tenant, append-only event_log, provenance, dead-letter,
-- actor model. Row-level security enforced from the first migration (JL-003 / #4).
-- Run idempotently by app startup against the postgres container.
-- Extensions
CREATE EXTENSION IF NOT EXISTS vector;
-- Tenant registry (commitment #4: tenancy at schema level day 1)
CREATE TABLE IF NOT EXISTS tenant (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Actor registry (agency first-class, JL-002): humans + digital agents, tier + audit
CREATE TABLE IF NOT EXISTS actor (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenant(id),
kind TEXT NOT NULL CHECK (kind IN ('human','digital')),
authority_tier INT NOT NULL CHECK (authority_tier BETWEEN 0 AND 3), -- T0..T3
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Append-only event log (commitment #2). No UPDATE/DELETE granted to app role.
-- create_capture: 'create' | 'capture' (new-market vs existing-demand probe)
-- confidence: 0..1 honest uncertainty
-- raw_ref: provenance pointer (source file / url / sheet cell / whatsapp msg id)
CREATE TABLE IF NOT EXISTS event_log (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenant(id),
actor_id TEXT NOT NULL, -- provenance (JL-002/#2)
schema_ver INT NOT NULL DEFAULT 1,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
create_capture TEXT CHECK (create_capture IN ('create','capture','unknown')),
confidence DOUBLE PRECISION CHECK (confidence BETWEEN 0 AND 1),
raw_ref TEXT, -- provenance (JL-002/#2)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_event_log_tenant ON event_log(tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_event_log_type ON event_log(tenant_id, event_type);
-- Dead-letter store for silent-loss prevention (JL-006/#2). Ingestion rejects land here.
CREATE TABLE IF NOT EXISTS dead_letter (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT,
reason TEXT NOT NULL,
raw_payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Row-level security: every table gated on tenant_id. App connects with a role that
-- can ONLY see its own tenant. DDL-level enforcement, not app-level.
ALTER TABLE event_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE actor ENABLE ROW LEVEL SECURITY;
ALTER TABLE dead_letter ENABLE ROW LEVEL SECURITY;
-- The app connects as the role set below; we scope it per-tenant at connect time by
-- setting a session variable and matching it in the policy. (Simplified for P1 single
-- app connection; in P6 this moves to per-tenant JWT claims via Zitadel.)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'vep_app') THEN
CREATE ROLE vep_app LOGIN PASSWORD 'vep_app'; -- overridden by .env in runtime
END IF;
END $$;
GRANT SELECT, INSERT ON ALL TABLES IN SCHEMA public TO vep_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vep_app;
-- Explicitly DENY mutations on the log (append-only at the privilege layer)
REVOKE UPDATE, DELETE ON event_log FROM vep_app;
REVOKE UPDATE, DELETE ON dead_letter FROM vep_app;
-- RLS policy: app role only sees rows matching its session tenant.
DROP POLICY IF EXISTS tenant_isolation_event_log ON event_log;
CREATE POLICY tenant_isolation_event_log ON event_log
FOR ALL TO vep_app
USING (tenant_id = current_setting('app.current_tenant', true));
DROP POLICY IF EXISTS tenant_isolation_actor ON actor;
CREATE POLICY tenant_isolation_actor ON actor
FOR ALL TO vep_app
USING (tenant_id = current_setting('app.current_tenant', true));
DROP POLICY IF EXISTS tenant_isolation_dead_letter ON dead_letter;
CREATE POLICY tenant_isolation_dead_letter ON dead_letter
FOR ALL TO vep_app
USING (tenant_id IS NULL OR tenant_id = current_setting('app.current_tenant', true));
-- Seed a default tenant + a human T0 owner actor so the spine has a first row.
INSERT INTO tenant (id, name) VALUES ('t_default','Default Tenant')
ON CONFLICT (id) DO NOTHING;
INSERT INTO actor (id, tenant_id, kind, authority_tier)
VALUES ('a_owner','t_default','human',0)
ON CONFLICT (id) DO NOTHING;