92 lines
4.3 KiB
SQL
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;
|