-- 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;