Tactical Media Broadcast Hub — initial release

Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
This commit is contained in:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,212 @@
-- 001: tenant schema, RLS, audit chain, lifecycle triggers
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE workspaces (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
slug text NOT NULL UNIQUE,
authz_epoch bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
external_id text NOT NULL UNIQUE,
email text NOT NULL,
display_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE memberships (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, user_id)
);
CREATE TABLE role_permissions (
role text NOT NULL,
permission text NOT NULL,
PRIMARY KEY (role, permission)
);
CREATE TABLE connections (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
provider text NOT NULL,
channel_id text NOT NULL,
fields jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE credentials (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
connection_id uuid REFERENCES connections(id) ON DELETE CASCADE,
kind text NOT NULL,
ciphertext bytea NOT NULL,
key_version int NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
rotated_at timestamptz
);
CREATE TABLE audit_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid REFERENCES workspaces(id) ON DELETE CASCADE,
actor_user_id uuid,
actor_role text,
action text NOT NULL,
target_type text NOT NULL,
target_id text NOT NULL,
diff jsonb,
at timestamptz NOT NULL DEFAULT now(),
seq bigint GENERATED ALWAYS AS IDENTITY,
prev_hash text,
hash text NOT NULL
);
CREATE INDEX audit_log_ws_chain ON audit_log (workspace_id, seq DESC) WHERE workspace_id IS NOT NULL;
CREATE INDEX audit_log_system_chain ON audit_log (seq DESC) WHERE workspace_id IS NULL;
-- Tenancy guard for SECURITY DEFINER functions (audit finding #3):
-- every cross-tenant function takes an explicit workspace parameter and
-- asserts it against the session's tenant context.
CREATE FUNCTION fn_assert_tenant(p_workspace_id uuid) RETURNS void
LANGUAGE plpgsql STABLE AS $$
DECLARE v_ctx text;
BEGIN
v_ctx := current_setting('app.workspace_id', true);
IF v_ctx IS NULL OR v_ctx = '' OR v_ctx::uuid IS DISTINCT FROM p_workspace_id THEN
RAISE EXCEPTION 'tenant context mismatch' USING ERRCODE = '42501';
END IF;
IF NOT EXISTS (SELECT 1 FROM workspaces w WHERE w.id = p_workspace_id) THEN
RAISE EXCEPTION 'unknown workspace' USING ERRCODE = '42501';
END IF;
END $$;
-- Hash chain: tenant rows chain per workspace; system rows chain on the
-- workspace_id IS NULL branch. Branch selection is by TENANT CONTEXT
-- PRESENCE, never by role (audit findings #1/#6: sp_scheduler/vault callers
-- with an active tenant context must append to that tenant's chain).
CREATE FUNCTION audit_hash(p_prev text, p_actor uuid, p_action text, p_target_id text, p_at timestamptz)
RETURNS text LANGUAGE sql IMMUTABLE AS $$
SELECT encode(digest(
coalesce(p_prev, '') || '|' || coalesce(p_actor::text, '') || '|' ||
p_action || '|' || p_target_id || '|' || extract(epoch FROM p_at)::text,
'sha256'), 'hex')
$$;
CREATE FUNCTION audit_append(
p_workspace_id uuid,
p_actor_user_id uuid,
p_actor_role text,
p_action text,
p_target_type text,
p_target_id text,
p_diff jsonb DEFAULT NULL
) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
DECLARE
v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp();
BEGIN
PERFORM fn_assert_tenant(p_workspace_id);
SELECT hash INTO v_prev FROM audit_log
WHERE workspace_id = p_workspace_id ORDER BY seq DESC LIMIT 1;
v_hash := audit_hash(v_prev, p_actor_user_id, p_action, p_target_id, v_now);
INSERT INTO audit_log (workspace_id, actor_user_id, actor_role, action, target_type, target_id, diff, at, prev_hash, hash)
VALUES (p_workspace_id, p_actor_user_id, p_actor_role, p_action, p_target_type, p_target_id, p_diff, v_now, v_prev, v_hash)
RETURNING id INTO v_id;
RETURN v_id;
END $$;
CREATE FUNCTION audit_append_system(
p_actor_user_id uuid,
p_action text,
p_target_type text,
p_target_id text,
p_diff jsonb DEFAULT NULL
) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
DECLARE
v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp();
BEGIN
IF coalesce(current_setting('app.workspace_id', true), '') <> '' THEN
RAISE EXCEPTION 'system append must not run inside a tenant context (use audit_append)';
END IF;
SELECT hash INTO v_prev FROM audit_log
WHERE workspace_id IS NULL ORDER BY seq DESC LIMIT 1;
v_hash := audit_hash(v_prev, p_actor_user_id, p_action, p_target_id, v_now);
INSERT INTO audit_log (workspace_id, actor_user_id, action, target_type, target_id, diff, at, prev_hash, hash)
VALUES (NULL, p_actor_user_id, p_action, p_target_type, p_target_id, p_diff, v_now, v_prev, v_hash)
RETURNING id INTO v_id;
RETURN v_id;
END $$;
-- Tamper detection: recompute the chain.
CREATE FUNCTION audit_verify_chain(p_workspace_id uuid DEFAULT NULL) RETURNS boolean
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$
DECLARE r record; v_prev text := NULL;
BEGIN
FOR r IN SELECT * FROM audit_log
WHERE workspace_id IS NOT DISTINCT FROM p_workspace_id
ORDER BY seq ASC LOOP
IF r.prev_hash IS DISTINCT FROM v_prev THEN RETURN false; END IF;
IF audit_hash(r.prev_hash, r.actor_user_id, r.action, r.target_id, r.at) <> r.hash THEN
RETURN false;
END IF;
v_prev := r.hash;
END LOOP;
RETURN true;
END $$;
-- Lifecycle immutability (audit finding #8): tenant rows never change tenant.
CREATE FUNCTION reject_workspace_change() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.workspace_id IS DISTINCT FROM OLD.workspace_id THEN
RAISE EXCEPTION 'workspace_id is immutable' USING ERRCODE = '23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_conn_ws_immutable BEFORE UPDATE ON connections
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_cred_ws_immutable BEFORE UPDATE ON credentials
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_mem_ws_immutable BEFORE UPDATE ON memberships
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
-- Continuous authz (audit finding #2): membership/role changes bump the
-- workspace authz epoch so live sessions fail closed on next check.
-- COALESCE matters: OLD is NULL-valued on INSERT, NEW on DELETE.
CREATE FUNCTION bump_authz_epoch() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
UPDATE workspaces SET authz_epoch = authz_epoch + 1
WHERE id = COALESCE(NEW.workspace_id, OLD.workspace_id);
RETURN COALESCE(NEW, OLD);
END $$;
CREATE TRIGGER trg_mem_bump_epoch AFTER INSERT OR UPDATE OR DELETE ON memberships
FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch();
-- RLS: fail closed. Missing app.workspace_id => NULL => no tenant rows visible.
-- FORCE: applies even to the table owner, so the single-role dev pool cannot
-- silently bypass tenancy; tenant writes must go through withTenant().
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE connections ENABLE ROW LEVEL SECURITY;
ALTER TABLE credentials ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE memberships FORCE ROW LEVEL SECURITY;
ALTER TABLE connections FORCE ROW LEVEL SECURITY;
ALTER TABLE credentials FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON memberships
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON connections
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON credentials
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_audit ON audit_log
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);

View file

@ -0,0 +1,90 @@
-- 002: RBAC seed — 37 permissions across 10 roles
-- publication.delete is a legacy seed (including Publisher). Deprecated for moderation;
-- future hide/delete routes must use dedicated moderation permissions from 011.
BEGIN;
INSERT INTO role_permissions (role, permission)
SELECT 'Owner', p FROM unnest(ARRAY[
'workspace.view','workspace.settings','workspace.members.manage',
'connection.view','connection.manage','credential.manage',
'draft.view','draft.create','draft.edit','approval.request','approval.grant',
'publication.publish','publication.schedule','publication.delete',
'media.view','media.manage','inbox.view','inbox.reply','stream.view',
'analytics.view','audit.view',
'virtualchannel.view','virtualchannel.create','virtualchannel.edit',
'director.view','director.take','director.override_auto','director.change_layout','director.lock_program',
'streamsource.manage','streamoutput.manage',
'livestream.view','livestream.manage',
'livechat.view','livechat.send','livechat.moderate'
]) AS p
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role, permission)
SELECT 'Admin', p FROM unnest(ARRAY[
'workspace.view','workspace.settings','workspace.members.manage',
'connection.view','connection.manage','credential.manage',
'draft.view','draft.create','draft.edit','approval.request','approval.grant',
'publication.publish','publication.schedule','publication.delete',
'media.view','media.manage','inbox.view','inbox.reply','stream.view',
'analytics.view','audit.view',
'virtualchannel.view','director.view','livestream.view','livechat.view'
]) AS p
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role, permission)
SELECT 'Publisher', p FROM unnest(ARRAY[
'workspace.view','connection.view','draft.view','draft.create','draft.edit',
'approval.request','publication.publish','publication.schedule','publication.delete',
'media.view','media.manage','inbox.view','inbox.reply','stream.view','analytics.view'
]) AS p
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role, permission)
SELECT 'Editor', p FROM unnest(ARRAY[
'workspace.view','connection.view','draft.view','draft.create','draft.edit',
'media.view','media.manage','inbox.view','inbox.reply','stream.view'
]) AS p
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role, permission)
SELECT 'Approver', p FROM unnest(ARRAY[
'workspace.view','draft.view','approval.grant','analytics.view','audit.view'
]) AS p
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role, permission)
SELECT 'Analyst', p FROM unnest(ARRAY[
'workspace.view','analytics.view','stream.view','audit.view'
]) AS p
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role, permission)
SELECT 'Moderator', p FROM unnest(ARRAY[
'workspace.view','inbox.view','inbox.reply',
'livechat.view','livechat.send','livechat.moderate'
]) AS p
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role, permission)
SELECT 'VideoPublisher', p FROM unnest(ARRAY[
'workspace.view','draft.view','draft.create','draft.edit',
'publication.publish','publication.schedule','media.view','media.manage'
]) AS p
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role, permission)
SELECT 'StreamOperator', p FROM unnest(ARRAY[
'workspace.view','livestream.view','livestream.manage','stream.view'
]) AS p
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role, permission)
SELECT 'BroadcastOperator', p FROM unnest(ARRAY[
'workspace.view','virtualchannel.view',
'director.view','director.take','director.override_auto','director.change_layout','director.lock_program',
'streamsource.manage','livestream.view',
'livechat.view','livechat.send','livechat.moderate'
]) AS p
ON CONFLICT DO NOTHING;
COMMIT;

View file

@ -0,0 +1,19 @@
-- 003: dedicated non-superuser app role for the runtime pool.
-- Migrations run as the table owner (superuser in dev); the application and
-- tests connect as sp_app — neither superuser nor owner — so FORCE ROW LEVEL
-- SECURITY binds for real.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'sp_app') THEN
CREATE ROLE sp_app LOGIN PASSWORD 'sp_app_dev' NOSUPERUSER NOCREATEDB NOCREATEROLE;
END IF;
END $$;
GRANT USAGE ON SCHEMA public TO sp_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO sp_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO sp_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO sp_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO sp_app;

View file

@ -0,0 +1,16 @@
-- 004: pooled sessions can carry an empty-string GUC placeholder after
-- SET LOCAL commits; NULLIF makes the RLS expressions fail closed cleanly
-- instead of raising a cast error.
DROP POLICY IF EXISTS tenant_isolation ON memberships;
DROP POLICY IF EXISTS tenant_isolation ON connections;
DROP POLICY IF EXISTS tenant_isolation ON credentials;
DROP POLICY IF EXISTS tenant_audit ON audit_log;
CREATE POLICY tenant_isolation ON memberships
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON connections
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON credentials
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_audit ON audit_log
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);

View file

@ -0,0 +1,10 @@
-- 005: fix epoch bump for INSERT (OLD is NULL on INSERT; the original
-- UPDATE matched zero rows silently). CREATE OR REPLACE also strips any
-- debug variant left in dev databases.
CREATE OR REPLACE FUNCTION bump_authz_epoch() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
UPDATE workspaces SET authz_epoch = authz_epoch + 1
WHERE id = COALESCE(NEW.workspace_id, OLD.workspace_id);
RETURN COALESCE(NEW, OLD);
END $$;

View file

@ -0,0 +1,89 @@
-- 006: publishing pipeline (drafts -> approval -> scheduling -> publication)
CREATE TABLE drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
author_user_id uuid NOT NULL REFERENCES users(id),
post jsonb NOT NULL,
state text NOT NULL DEFAULT 'editing'
CHECK (state IN ('editing', 'in_review', 'approved', 'rejected', 'scheduled', 'published')),
reviewer_user_id uuid REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE scheduled_publications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
draft_id uuid NOT NULL REFERENCES drafts(id) ON DELETE CASCADE,
connection_id uuid NOT NULL REFERENCES connections(id),
publish_at timestamptz NOT NULL,
idempotency_key text NOT NULL UNIQUE,
state text NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending', 'in_flight', 'done', 'failed')),
error text,
UNIQUE (draft_id)
);
CREATE TABLE publications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
draft_id uuid NOT NULL REFERENCES drafts(id) ON DELETE CASCADE,
connection_id uuid NOT NULL REFERENCES connections(id),
idempotency_key text NOT NULL UNIQUE,
remote_id text NOT NULL,
remote_url text,
published_at timestamptz NOT NULL,
provider_metadata jsonb NOT NULL DEFAULT '{}'
);
CREATE INDEX drafts_workspace_state ON drafts (workspace_id, state);
CREATE INDEX scheduled_publications_due ON scheduled_publications (publish_at)
WHERE state = 'pending';
CREATE INDEX publications_draft ON publications (draft_id);
CREATE TRIGGER trg_draft_ws_immutable BEFORE UPDATE ON drafts
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_scheduled_pub_ws_immutable BEFORE UPDATE ON scheduled_publications
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_publication_ws_immutable BEFORE UPDATE ON publications
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE drafts ENABLE ROW LEVEL SECURITY;
ALTER TABLE scheduled_publications ENABLE ROW LEVEL SECURITY;
ALTER TABLE publications ENABLE ROW LEVEL SECURITY;
ALTER TABLE drafts FORCE ROW LEVEL SECURITY;
ALTER TABLE scheduled_publications FORCE ROW LEVEL SECURITY;
ALTER TABLE publications FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON drafts
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON scheduled_publications
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON publications
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON drafts, scheduled_publications, publications TO sp_app;
-- Narrow SECURITY DEFINER lookups let the scheduler discover tenant context
-- without weakening FORCE RLS for ordinary table access.
CREATE FUNCTION publishing_schedule_workspace(p_idempotency_key text) RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT workspace_id
FROM scheduled_publications
WHERE idempotency_key = p_idempotency_key
$$;
CREATE FUNCTION publishing_due_schedules() RETURNS TABLE(idempotency_key text, publish_at timestamptz)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT sp.idempotency_key, sp.publish_at
FROM scheduled_publications sp
WHERE sp.state = 'pending' AND sp.publish_at <= now()
ORDER BY sp.publish_at
$$;
REVOKE ALL ON FUNCTION publishing_schedule_workspace(text) FROM PUBLIC;
REVOKE ALL ON FUNCTION publishing_due_schedules() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION publishing_schedule_workspace(text), publishing_due_schedules() TO sp_app;

View file

@ -0,0 +1,30 @@
-- 007: first-party auth (email + password) and membership invitations.
-- Passwords live in their own table (never in users, never in audit diffs);
-- hashes are scrypt with a per-user salt, produced by apps/api/src/auth/password.ts.
CREATE TABLE user_passwords (
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
password_hash text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Invited members exist as memberships before they first sign in.
ALTER TABLE memberships
ADD COLUMN status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'invited'));
GRANT SELECT, INSERT, UPDATE, DELETE ON user_passwords TO sp_app;
-- Login happens before any tenant context exists, and memberships is FORCE
-- RLS. This narrow SECURITY DEFINER lookup returns only the caller-supplied
-- user's own memberships so login can pick a workspace without weakening RLS.
CREATE FUNCTION auth_user_memberships(p_user_id uuid)
RETURNS TABLE(workspace_id uuid, role text, status text, created_at timestamptz)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT m.workspace_id, m.role, m.status, m.created_at
FROM memberships m
WHERE m.user_id = p_user_id
ORDER BY m.created_at ASC
$$;
REVOKE ALL ON FUNCTION auth_user_memberships(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION auth_user_memberships(uuid) TO sp_app;

View file

@ -0,0 +1,104 @@
-- 008: groups — sub-workspace sharing scopes for connections and assets.
-- Group membership gates which connections a non-Owner/Admin can publish through.
--
-- Child tables carry a denormalised workspace_id so that:
-- * RLS is the same direct column predicate as every other tenant table
-- (no subquery policies);
-- * composite FKs pin every row to ONE workspace — a group cannot hold a
-- connection or a member from another tenant, even via the owner pool;
-- * the epoch bump can read workspace_id from the row itself. Looking it up
-- in groups would miss cascaded deletes (DELETE FROM groups -> its
-- group_members rows), because the parent row is already gone when the
-- child's AFTER trigger runs — deleting a group would silently skip the bump.
-- Composite FK targets.
ALTER TABLE connections ADD CONSTRAINT connections_id_workspace_key UNIQUE (id, workspace_id);
CREATE TABLE groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name text NOT NULL CHECK (btrim(name) <> ''),
description text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, name),
UNIQUE (id, workspace_id)
);
CREATE TABLE group_members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
group_id uuid NOT NULL,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
added_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (group_id, user_id),
FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE,
-- Only workspace members can join a group; leaving the workspace leaves its groups.
FOREIGN KEY (workspace_id, user_id) REFERENCES memberships(workspace_id, user_id) ON DELETE CASCADE
);
CREATE TABLE group_connections (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
group_id uuid NOT NULL,
connection_id uuid NOT NULL,
assigned_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (group_id, connection_id),
FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE,
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections(id, workspace_id) ON DELETE CASCADE
);
CREATE TABLE group_assets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
group_id uuid NOT NULL,
asset_id text NOT NULL CHECK (btrim(asset_id) <> ''), -- media asset storage key or id
assigned_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (group_id, asset_id),
FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE
);
CREATE INDEX group_members_user ON group_members (workspace_id, user_id);
CREATE INDEX group_connections_connection ON group_connections (connection_id);
CREATE TRIGGER trg_group_ws_immutable BEFORE UPDATE ON groups
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_group_member_ws_immutable BEFORE UPDATE ON group_members
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_group_connection_ws_immutable BEFORE UPDATE ON group_connections
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_group_asset_ws_immutable BEFORE UPDATE ON group_assets
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
-- Continuous authz: group membership is an authorization input (it gates
-- publishing), so changes bump the workspace epoch exactly like memberships.
-- Reuses bump_authz_epoch() (001/005): it reads the row's own workspace_id,
-- which also covers cascades from DELETE FROM groups / memberships.
CREATE TRIGGER trg_group_member_bump_epoch AFTER INSERT OR UPDATE OR DELETE ON group_members
FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch();
ALTER TABLE groups ENABLE ROW LEVEL SECURITY;
ALTER TABLE group_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE group_connections ENABLE ROW LEVEL SECURITY;
ALTER TABLE group_assets ENABLE ROW LEVEL SECURITY;
ALTER TABLE groups FORCE ROW LEVEL SECURITY;
ALTER TABLE group_members FORCE ROW LEVEL SECURITY;
ALTER TABLE group_connections FORCE ROW LEVEL SECURITY;
ALTER TABLE group_assets FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON groups
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON group_members
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON group_connections
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON group_assets
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON groups, group_members, group_connections, group_assets TO sp_app;

View file

@ -0,0 +1,52 @@
-- 009: first-class video assets and their processing lifecycle
CREATE TABLE video_assets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
storage_key text NOT NULL,
mime_type text NOT NULL,
size_bytes bigint NOT NULL,
width int,
height int,
duration_ms int,
frame_rate numeric,
codec text,
audio_codec text,
thumbnail_key text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE video_processing_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
video_asset_id uuid NOT NULL REFERENCES video_assets(id) ON DELETE CASCADE,
state text NOT NULL CHECK (state IN ('queued', 'probing', 'transcoding', 'complete', 'failed')),
progress_percent int NOT NULL DEFAULT 0,
output_key text,
error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX video_assets_workspace_created
ON video_assets (workspace_id, created_at DESC, id DESC);
CREATE INDEX video_processing_jobs_asset_created
ON video_processing_jobs (video_asset_id, created_at DESC);
CREATE TRIGGER trg_video_asset_ws_immutable BEFORE UPDATE ON video_assets
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_video_job_ws_immutable BEFORE UPDATE ON video_processing_jobs
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE video_assets ENABLE ROW LEVEL SECURITY;
ALTER TABLE video_processing_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE video_assets FORCE ROW LEVEL SECURITY;
ALTER TABLE video_processing_jobs FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON video_assets
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON video_processing_jobs
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON video_assets, video_processing_jobs TO sp_app;

View file

@ -0,0 +1,80 @@
-- 010: hash the audit diff and serialize appends to each chain.
BEGIN;
ALTER TABLE audit_log ADD COLUMN hash_version smallint NOT NULL DEFAULT 1
CHECK (hash_version IN (1, 2));
CREATE FUNCTION audit_hash_v2(p_prev text, p_actor uuid, p_action text, p_target_id text,
p_at timestamptz, p_diff jsonb)
RETURNS text LANGUAGE sql IMMUTABLE AS $$
SELECT encode(digest(
coalesce(p_prev, '') || '|' || coalesce(p_actor::text, '') || '|' ||
p_action || '|' || p_target_id || '|' || extract(epoch FROM p_at)::text || '|' ||
coalesce(p_diff::text, ''),
'sha256'), 'hex')
$$;
CREATE OR REPLACE FUNCTION audit_append(
p_workspace_id uuid, p_actor_user_id uuid, p_actor_role text, p_action text,
p_target_type text, p_target_id text, p_diff jsonb DEFAULT NULL
) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
DECLARE
v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp();
BEGIN
PERFORM fn_assert_tenant(p_workspace_id);
PERFORM pg_advisory_xact_lock(hashtext('audit:' || p_workspace_id::text));
SELECT hash INTO v_prev FROM audit_log
WHERE workspace_id = p_workspace_id ORDER BY seq DESC LIMIT 1;
v_hash := audit_hash_v2(v_prev, p_actor_user_id, p_action, p_target_id, v_now, p_diff);
INSERT INTO audit_log (workspace_id, actor_user_id, actor_role, action, target_type, target_id,
diff, at, prev_hash, hash, hash_version)
VALUES (p_workspace_id, p_actor_user_id, p_actor_role, p_action, p_target_type, p_target_id,
p_diff, v_now, v_prev, v_hash, 2)
RETURNING id INTO v_id;
RETURN v_id;
END $$;
CREATE OR REPLACE FUNCTION audit_append_system(
p_actor_user_id uuid, p_action text, p_target_type text, p_target_id text,
p_diff jsonb DEFAULT NULL
) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
DECLARE
v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp();
BEGIN
IF coalesce(current_setting('app.workspace_id', true), '') <> '' THEN
RAISE EXCEPTION 'system append must not run inside a tenant context (use audit_append)';
END IF;
PERFORM pg_advisory_xact_lock(hashtext('audit:system'));
SELECT hash INTO v_prev FROM audit_log
WHERE workspace_id IS NULL ORDER BY seq DESC LIMIT 1;
v_hash := audit_hash_v2(v_prev, p_actor_user_id, p_action, p_target_id, v_now, p_diff);
INSERT INTO audit_log (workspace_id, actor_user_id, action, target_type, target_id,
diff, at, prev_hash, hash, hash_version)
VALUES (NULL, p_actor_user_id, p_action, p_target_type, p_target_id,
p_diff, v_now, v_prev, v_hash, 2)
RETURNING id INTO v_id;
RETURN v_id;
END $$;
CREATE OR REPLACE FUNCTION audit_verify_chain(p_workspace_id uuid DEFAULT NULL) RETURNS boolean
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$
DECLARE r record; v_prev text := NULL; v_expected text;
BEGIN
FOR r IN SELECT * FROM audit_log
WHERE workspace_id IS NOT DISTINCT FROM p_workspace_id
ORDER BY seq ASC LOOP
IF r.prev_hash IS DISTINCT FROM v_prev THEN RETURN false; END IF;
IF r.hash_version = 1 THEN
v_expected := audit_hash(r.prev_hash, r.actor_user_id, r.action, r.target_id, r.at);
ELSE
v_expected := audit_hash_v2(r.prev_hash, r.actor_user_id, r.action, r.target_id, r.at, r.diff);
END IF;
IF v_expected IS DISTINCT FROM r.hash THEN RETURN false; END IF;
v_prev := r.hash;
END LOOP;
RETURN true;
END $$;
COMMIT;

View file

@ -0,0 +1,114 @@
-- 012: WP8 Live Source Monitor — per-source reducer state + observation dedupe.
-- (010/011 are reserved for the WP7 egress package; the monitor consumes those
-- budgets later and deliberately carries no rate machinery of its own.)
--
-- One row per monitored source identity (connection_id, remote_ref): the keyed
-- partition the MonitorObservation reducer folds into (proposal §4.4). Each
-- child row carries a denormalised workspace_id so RLS stays a direct column
-- predicate and composite FKs pin the row to ONE workspace's connection.
--
-- `version` is the per-source compare-and-swap token: every applied mutation
-- bumps it, updates are guarded by `WHERE ... AND version = $n`, conflicting
-- writers reload and re-evaluate, and downstream consumers (WS fan-out,
-- outbox) drop non-increasing versions.
CREATE TABLE source_monitor_states (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
connection_id uuid NOT NULL,
remote_ref text NOT NULL CHECK (btrim(remote_ref) <> ''),
state text NOT NULL DEFAULT 'OFFLINE'
CHECK (state IN ('OFFLINE', 'PROBING', 'LIVE', 'UNSTABLE', 'ERROR')),
version bigint NOT NULL DEFAULT 0 CHECK (version >= 0),
-- Highest *available* signal tier; NULL until the first fresh signal lands.
authoritative_tier text
CHECK (authoritative_tier IN ('media-health', 'hls-probe', 'webhook', 'api-poll')),
-- Last applied observedAt per signal tier. Staleness is judged WITHIN one
-- tier only (provider clocks are not comparable across signal sources).
last_media_health_at timestamptz,
last_hls_probe_at timestamptz,
last_webhook_at timestamptz,
last_api_poll_at timestamptz,
-- Remaining per-tier reducer bookkeeping: lastOutcome, lastProviderEventId,
-- deniedUntil, failStreak. jsonb keeps the reducer state shape in one place.
tier_status jsonb NOT NULL DEFAULT '{}'::jsonb,
ok_streak int NOT NULL DEFAULT 0 CHECK (ok_streak >= 0),
fail_streak int NOT NULL DEFAULT 0 CHECK (fail_streak >= 0),
state_entered_at timestamptz NOT NULL DEFAULT now(),
-- Hysteresis: min-hold keeps a freshly entered state from being cut down,
-- cooldown blocks the reverse transition (no rapid A -> B -> A).
hold_until timestamptz,
cooldown_until timestamptz,
previous_state text CHECK (previous_state IN ('OFFLINE', 'PROBING', 'LIVE', 'UNSTABLE', 'ERROR')),
last_transition_at timestamptz,
last_transition_reason text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, connection_id, remote_ref),
UNIQUE (id, workspace_id),
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id) ON DELETE CASCADE
);
CREATE INDEX source_monitor_states_ws_created
ON source_monitor_states (workspace_id, created_at DESC, id DESC);
CREATE INDEX source_monitor_states_ws_state
ON source_monitor_states (workspace_id, state);
-- Observation dedupe is enforced by the DB, not the app (proposal §4.4): a
-- replayed providerEventId can never apply twice, regardless of which process
-- or replica delivers it. Rows are journal entries, never mutated.
CREATE TABLE monitor_seen_events (
workspace_id uuid NOT NULL,
provider_event_id text NOT NULL CHECK (btrim(provider_event_id) <> ''),
source_monitor_state_id uuid NOT NULL,
seen_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, provider_event_id),
FOREIGN KEY (source_monitor_state_id, workspace_id)
REFERENCES source_monitor_states (id, workspace_id) ON DELETE CASCADE
);
CREATE INDEX monitor_seen_events_seen_at ON monitor_seen_events (seen_at);
CREATE TRIGGER trg_monitor_state_ws_immutable BEFORE UPDATE ON source_monitor_states
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_monitor_seen_event_ws_immutable BEFORE UPDATE ON monitor_seen_events
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE source_monitor_states ENABLE ROW LEVEL SECURITY;
ALTER TABLE monitor_seen_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE source_monitor_states FORCE ROW LEVEL SECURITY;
ALTER TABLE monitor_seen_events FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON source_monitor_states
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON monitor_seen_events
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON source_monitor_states, monitor_seen_events TO sp_app;
-- Narrow SECURITY DEFINER lookup: the monitor/scheduler processes discover the
-- tenant that owns a source without weakening FORCE RLS for ordinary access
-- (same standing as publishing_schedule_workspace in 006).
CREATE FUNCTION live_monitor_source_workspace(p_connection_id uuid, p_remote_ref text) RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT workspace_id
FROM source_monitor_states
WHERE connection_id = p_connection_id AND remote_ref = p_remote_ref
LIMIT 1
$$;
-- Retention for the dedupe journal. Runs in system context on purpose: pruned
-- rows span tenants, and a tenant that stops recording must not leak journal
-- rows forever.
CREATE FUNCTION monitor_prune_seen_events(p_before timestamptz) RETURNS integer
LANGUAGE sql SECURITY DEFINER SET search_path = public AS $$
WITH removed AS (
DELETE FROM monitor_seen_events WHERE seen_at < p_before RETURNING 1
)
SELECT count(*) FROM removed
$$;
REVOKE ALL ON FUNCTION live_monitor_source_workspace(uuid, text) FROM PUBLIC;
REVOKE ALL ON FUNCTION monitor_prune_seen_events(timestamptz) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION live_monitor_source_workspace(uuid, text), monitor_prune_seen_events(timestamptz) TO sp_app;

View file

@ -0,0 +1,327 @@
-- 013: Streaming slice S1 — production schema (docs/design/streaming-architecture.md
-- §2, §4, §6 S1; proposal §4.4 same-channel invariant).
--
-- No virtual channel or channel-source table existed before this migration, so
-- 013 creates the minimal `virtual_channels` / `virtual_channel_sources` the
-- production tables hang off. `virtual_channel_sources.kind` is born with
-- `playlist` alongside `hls` (§5.1: a playlist is an ordinary ingest kind).
--
-- Same-channel invariant (structural, not a handler check): every production
-- row carries `virtual_channel_id`, and every reference to a scene, source or
-- overlay goes through a composite FK that includes (workspace_id,
-- virtual_channel_id). A slot can only reference a source of its own channel,
-- a bus can only hold a scene of its own channel, an overlay revision can only
-- belong to an overlay slot of its own channel — a cross-channel write fails
-- at the constraint, whichever code path issued it.
--
-- Guard FKs (a reference that must block a direct delete but not a purge)
-- are NO ACTION *and* DEFERRABLE INITIALLY DEFERRED. Plain NO ACTION is
-- checked when each cascade step's RI trigger fires, so a workspace purge that
-- reaches video_assets before it reaches playlist_items (deeper in the
-- workspace -> channel -> source -> item chain) aborts mid-cascade. Deferred,
-- the check runs at commit: a purge succeeds, a direct delete still fails.
--
-- Geometry is integer basis points of the output frame (0..10000), matching
-- the desired-state v2 type layer (apps/api/src/media-control/production.ts).
-- Video assets get the composite key that playlist items reference, so a
-- playlist can never name another tenant's asset by raw id.
ALTER TABLE video_assets ADD CONSTRAINT video_assets_id_workspace_key UNIQUE (id, workspace_id);
-- Ownership columns (the row's parent aggregate) are immutable: moving a row
-- to another parent is delete + create. A column listed with a leading '?' is
-- an `ON DELETE SET NULL` reference: only its non-null -> NULL transition (the
-- FK action itself) is permitted.
CREATE FUNCTION reject_owner_change() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
col text;
nullable boolean;
old_value text;
new_value text;
BEGIN
FOREACH col IN ARRAY TG_ARGV LOOP
nullable := left(col, 1) = '?';
IF nullable THEN col := substr(col, 2); END IF;
old_value := to_jsonb(OLD) ->> col;
new_value := to_jsonb(NEW) ->> col;
IF new_value IS DISTINCT FROM old_value AND NOT (nullable AND new_value IS NULL) THEN
RAISE EXCEPTION '%.% is immutable', TG_TABLE_NAME, col USING ERRCODE = '23514';
END IF;
END LOOP;
RETURN NEW;
END $$;
CREATE TABLE virtual_channels (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
name text NOT NULL CHECK (btrim(name) <> '' AND length(name) <= 120),
desired_status text NOT NULL DEFAULT 'STOPPED' CHECK (desired_status IN ('STOPPED', 'RUNNING')),
observed_status text NOT NULL DEFAULT 'STOPPED'
CHECK (observed_status IN ('PENDING_CAPACITY', 'STOPPED', 'STARTING', 'RUNNING', 'DEGRADED', 'RESTARTING', 'FAILED', 'UNKNOWN')),
-- The single per-channel desiredVersion fence: every desired-state tx
-- (TAKE, scene edit, overlay swap, gain step) locks this row FOR UPDATE and
-- bumps it once.
desired_version bigint NOT NULL DEFAULT 0 CHECK (desired_version >= 0),
observed_version bigint NOT NULL DEFAULT 0 CHECK (observed_version >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, slug),
UNIQUE (workspace_id, id)
);
CREATE TABLE virtual_channel_sources (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
virtual_channel_id uuid NOT NULL,
kind text NOT NULL CHECK (kind IN ('hls', 'playlist')),
label text NOT NULL CHECK (label ~ '^[[:alnum:]][[:alnum:] _.-]{0,63}$'),
priority int NOT NULL DEFAULT 0 CHECK (priority >= 0),
-- Connection the source was imported through (a candidate poller); NULL for
-- playlists and hand-entered URLs.
connection_id uuid,
-- Remote identity (import dedupe); a playlist is local and has none.
remote_ref text CHECK (remote_ref IS NULL OR btrim(remote_ref) <> ''),
hls_url text CHECK (hls_url IS NULL OR (hls_url ~ '^https://' AND length(hls_url) <= 2048)),
playlist_loop boolean,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (
(kind = 'hls' AND hls_url IS NOT NULL AND playlist_loop IS NULL) OR
(kind = 'playlist' AND hls_url IS NULL AND remote_ref IS NULL AND connection_id IS NULL AND playlist_loop IS NOT NULL)
),
UNIQUE (workspace_id, virtual_channel_id, remote_ref),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, virtual_channel_id, id),
FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id)
ON DELETE CASCADE ON UPDATE RESTRICT,
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED
);
CREATE INDEX virtual_channel_sources_priority ON virtual_channel_sources (workspace_id, virtual_channel_id, priority);
CREATE INDEX virtual_channel_sources_connection ON virtual_channel_sources (connection_id, workspace_id) WHERE connection_id IS NOT NULL;
-- Ordered, pre-normalized assets of a playlist source (the worker's asset feeder walks these).
CREATE TABLE playlist_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
virtual_channel_id uuid NOT NULL,
source_id uuid NOT NULL,
position int NOT NULL CHECK (position >= 0 AND position < 500),
video_asset_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, source_id, position),
UNIQUE (workspace_id, id),
FOREIGN KEY (workspace_id, virtual_channel_id, source_id) REFERENCES virtual_channel_sources (workspace_id, virtual_channel_id, id)
ON DELETE CASCADE ON UPDATE RESTRICT,
FOREIGN KEY (video_asset_id, workspace_id) REFERENCES video_assets (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED
);
CREATE INDEX playlist_items_asset ON playlist_items (video_asset_id, workspace_id);
CREATE TABLE production_scenes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
virtual_channel_id uuid NOT NULL,
label text NOT NULL CHECK (label ~ '^[[:alnum:]][[:alnum:] _.-]{0,63}$'),
layout text NOT NULL CHECK (layout IN ('single', 'pip', '2x1', '2x2', '3x3', '4x4', 'custom')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, virtual_channel_id, id),
FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id)
ON DELETE CASCADE ON UPDATE RESTRICT
);
CREATE INDEX production_scenes_channel ON production_scenes (workspace_id, virtual_channel_id);
CREATE TABLE scene_slots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
virtual_channel_id uuid NOT NULL,
scene_id uuid NOT NULL,
slot_index int NOT NULL CHECK (slot_index >= 0 AND slot_index < 16),
-- NULL = empty slot (renders the fallback); also the ON DELETE SET NULL target.
source_id uuid,
-- Explicit rectangle for custom layouts and PIP insets; all NULL = the layout's own cell.
x int, y int, w int, h int,
fit text NOT NULL DEFAULT 'contain' CHECK (fit IN ('contain', 'cover', 'crop')),
audio_mode text NOT NULL DEFAULT 'off' CHECK (audio_mode IN ('off', 'on', 'primary')),
gain_db smallint NOT NULL DEFAULT 0 CHECK (gain_db BETWEEN -24 AND 0 AND gain_db % 3 = 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK ((x IS NULL AND y IS NULL AND w IS NULL AND h IS NULL) OR
(x IS NOT NULL AND y IS NOT NULL AND w IS NOT NULL AND h IS NOT NULL)),
CHECK (x >= 0 AND y >= 0 AND w > 0 AND h > 0),
CHECK (x + w <= 10000 AND y + h <= 10000),
UNIQUE (workspace_id, scene_id, slot_index),
UNIQUE (workspace_id, id),
FOREIGN KEY (workspace_id, virtual_channel_id, scene_id) REFERENCES production_scenes (workspace_id, virtual_channel_id, id)
ON DELETE CASCADE ON UPDATE RESTRICT,
-- Column-list SET NULL (PostgreSQL >= 15): empties the slot, keeps tenant + channel set.
FOREIGN KEY (workspace_id, virtual_channel_id, source_id) REFERENCES virtual_channel_sources (workspace_id, virtual_channel_id, id)
ON DELETE SET NULL (source_id) ON UPDATE RESTRICT
);
-- At most one primary audio slot per scene (program mix default: primary-only).
CREATE UNIQUE INDEX scene_slots_one_primary ON scene_slots (workspace_id, scene_id) WHERE audio_mode = 'primary';
CREATE INDEX scene_slots_source ON scene_slots (workspace_id, virtual_channel_id, source_id) WHERE source_id IS NOT NULL;
CREATE INDEX scene_slots_scene ON scene_slots (workspace_id, virtual_channel_id, scene_id);
CREATE TABLE production_buses (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
virtual_channel_id uuid NOT NULL,
bus text NOT NULL CHECK (bus IN ('preview', 'program')),
-- Declared mutable reference: TAKE/CUT re-point it in place. NULL = empty bus.
-- NO ACTION: a scene on a bus cannot be deleted directly, but a channel
-- cascade that removes both succeeds (checked at end of statement).
scene_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, virtual_channel_id, bus),
UNIQUE (workspace_id, id),
FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id)
ON DELETE CASCADE ON UPDATE RESTRICT,
FOREIGN KEY (workspace_id, virtual_channel_id, scene_id) REFERENCES production_scenes (workspace_id, virtual_channel_id, id)
ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED
);
CREATE INDEX production_buses_scene ON production_buses (workspace_id, virtual_channel_id, scene_id) WHERE scene_id IS NOT NULL;
-- An overlay slot sits on one scene; toggling `enabled` arms it on PST and it
-- goes live with TAKE. Its pixels are always a pre-rendered asset (§4.2)
-- except the server-generated clock, whose format is a closed enum.
CREATE TABLE overlay_slots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
virtual_channel_id uuid NOT NULL,
scene_id uuid NOT NULL,
kind text NOT NULL CHECK (kind IN ('lower_third', 'chyron', 'logo', 'clock')),
enabled boolean NOT NULL DEFAULT false,
layer smallint NOT NULL DEFAULT 0 CHECK (layer BETWEEN 0 AND 7),
pos_x int NOT NULL DEFAULT 0 CHECK (pos_x BETWEEN 0 AND 10000),
pos_y int NOT NULL DEFAULT 0 CHECK (pos_y BETWEEN 0 AND 10000),
chyron_speed_px_s smallint CHECK (chyron_speed_px_s BETWEEN 40 AND 400),
chyron_strip_width_px int CHECK (chyron_strip_width_px BETWEEN 1 AND 16384),
clock_format text CHECK (clock_format IN ('HH:MM', 'HH:MM:SS')),
-- Revision currently composited; NULL until the first render lands.
current_revision_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK ((kind = 'chyron') = (chyron_speed_px_s IS NOT NULL AND chyron_strip_width_px IS NOT NULL)),
CHECK (kind = 'chyron' OR (chyron_speed_px_s IS NULL AND chyron_strip_width_px IS NULL)),
CHECK ((kind = 'clock') = (clock_format IS NOT NULL)),
CHECK (kind <> 'clock' OR current_revision_id IS NULL),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, virtual_channel_id, id),
FOREIGN KEY (workspace_id, virtual_channel_id, scene_id) REFERENCES production_scenes (workspace_id, virtual_channel_id, id)
ON DELETE CASCADE ON UPDATE RESTRICT
);
CREATE INDEX overlay_slots_scene ON overlay_slots (workspace_id, virtual_channel_id, scene_id);
-- Append-only history of an overlay slot's validated content and its rendered
-- asset. FFmpeg only ever sees `asset_id` (sha256 content id from StorageService).
CREATE TABLE overlay_revisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
virtual_channel_id uuid NOT NULL,
overlay_slot_id uuid NOT NULL,
template_id text NOT NULL CHECK (template_id ~ '^[a-z0-9][a-z0-9_-]{0,63}$'),
-- Typed field values (title <= 80, subtitle <= 120, style enum), validated in the app layer.
fields jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(fields) = 'object'),
render_state text NOT NULL DEFAULT 'pending' CHECK (render_state IN ('pending', 'rendered', 'failed')),
asset_id text CHECK (asset_id ~ '^[0-9a-f]{64}$'),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK ((render_state = 'rendered') = (asset_id IS NOT NULL)),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, virtual_channel_id, overlay_slot_id, id),
FOREIGN KEY (workspace_id, virtual_channel_id, overlay_slot_id) REFERENCES overlay_slots (workspace_id, virtual_channel_id, id)
ON DELETE CASCADE ON UPDATE RESTRICT
);
CREATE INDEX overlay_revisions_slot ON overlay_revisions (workspace_id, virtual_channel_id, overlay_slot_id, created_at DESC);
-- The current revision must be one of THIS slot's revisions (same channel, same slot).
ALTER TABLE overlay_slots ADD CONSTRAINT overlay_slots_current_revision_fkey
FOREIGN KEY (workspace_id, virtual_channel_id, id, current_revision_id)
REFERENCES overlay_revisions (workspace_id, virtual_channel_id, overlay_slot_id, id)
ON DELETE SET NULL (current_revision_id) ON UPDATE RESTRICT;
CREATE INDEX overlay_slots_current_revision ON overlay_slots (workspace_id, virtual_channel_id, id, current_revision_id)
WHERE current_revision_id IS NOT NULL;
-- Immutability: tenant column everywhere, ownership columns per table.
CREATE TRIGGER trg_virtual_channel_ws_immutable BEFORE UPDATE ON virtual_channels
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_vc_source_ws_immutable BEFORE UPDATE ON virtual_channel_sources
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_vc_source_owner_immutable BEFORE UPDATE ON virtual_channel_sources
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'kind');
CREATE TRIGGER trg_playlist_item_ws_immutable BEFORE UPDATE ON playlist_items
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_playlist_item_owner_immutable BEFORE UPDATE ON playlist_items
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'source_id');
CREATE TRIGGER trg_scene_ws_immutable BEFORE UPDATE ON production_scenes
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_scene_owner_immutable BEFORE UPDATE ON production_scenes
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id');
CREATE TRIGGER trg_scene_slot_ws_immutable BEFORE UPDATE ON scene_slots
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_scene_slot_owner_immutable BEFORE UPDATE ON scene_slots
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'scene_id', '?source_id');
CREATE TRIGGER trg_bus_ws_immutable BEFORE UPDATE ON production_buses
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_bus_owner_immutable BEFORE UPDATE ON production_buses
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'bus');
CREATE TRIGGER trg_overlay_slot_ws_immutable BEFORE UPDATE ON overlay_slots
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_overlay_slot_owner_immutable BEFORE UPDATE ON overlay_slots
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'scene_id', 'kind');
CREATE TRIGGER trg_overlay_revision_ws_immutable BEFORE UPDATE ON overlay_revisions
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_overlay_revision_owner_immutable BEFORE UPDATE ON overlay_revisions
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id', 'overlay_slot_id', 'template_id', 'fields');
ALTER TABLE virtual_channels ENABLE ROW LEVEL SECURITY;
ALTER TABLE virtual_channel_sources ENABLE ROW LEVEL SECURITY;
ALTER TABLE playlist_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE production_scenes ENABLE ROW LEVEL SECURITY;
ALTER TABLE scene_slots ENABLE ROW LEVEL SECURITY;
ALTER TABLE production_buses ENABLE ROW LEVEL SECURITY;
ALTER TABLE overlay_slots ENABLE ROW LEVEL SECURITY;
ALTER TABLE overlay_revisions ENABLE ROW LEVEL SECURITY;
ALTER TABLE virtual_channels FORCE ROW LEVEL SECURITY;
ALTER TABLE virtual_channel_sources FORCE ROW LEVEL SECURITY;
ALTER TABLE playlist_items FORCE ROW LEVEL SECURITY;
ALTER TABLE production_scenes FORCE ROW LEVEL SECURITY;
ALTER TABLE scene_slots FORCE ROW LEVEL SECURITY;
ALTER TABLE production_buses FORCE ROW LEVEL SECURITY;
ALTER TABLE overlay_slots FORCE ROW LEVEL SECURITY;
ALTER TABLE overlay_revisions FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON virtual_channels
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON virtual_channel_sources
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON playlist_items
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON production_scenes
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON scene_slots
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON production_buses
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON overlay_slots
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON overlay_revisions
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON virtual_channels, virtual_channel_sources, playlist_items,
production_scenes, scene_slots, production_buses, overlay_slots, overlay_revisions TO sp_app;

View file

@ -0,0 +1,236 @@
-- 014: Streaming slice S5 — chat hub + device pairing
-- (docs/design/streaming-architecture.md §3, §6 S5).
--
-- Design notes:
-- * `live_chat_messages` is keyed by (workspace, channel) — the chat hub's
-- aggregate is a virtual channel's live chat, and the seq cursor a client
-- resumes with is per channel. Global provider dedupe uses a nonpartitioned
-- claim journal; the paging index is (workspace_id, channel_id, seq).
-- * Ordering is a hub-assigned `seq` under a row lock on the channel's
-- chat_seq row; provider timestamps stay display-only (clocks skew, §3.2).
-- * Monthly received_at partitions retain ingress ordering. PostgreSQL needs
-- the partition key in unique constraints, so global provider dedupe lives
-- in the nonpartitioned live_chat_seen_events journal, claimed in the same
-- transaction as the message insert.
-- * Devices and pairings carry their own workspace column and direct
-- workspace FK so revocation is workspace-scoped; user_id references
-- users(id) ON DELETE CASCADE so deleting a user removes their devices.
--
-- RLS / NULLIF / sp_app grants follow 012/013 conventions.
-- One row per (workspace, channel) holding the chat sequence. FOR UPDATE on
-- this row inside the ingest transaction is what makes seq assignment monotonic
-- per channel (§3.2 "monotonic seq assigned at hub ingress").
CREATE TABLE live_chat_seq (
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
channel_id uuid NOT NULL,
seq bigint NOT NULL DEFAULT 0 CHECK (seq >= 0),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, channel_id),
FOREIGN KEY (channel_id, workspace_id) REFERENCES virtual_channels (id, workspace_id)
ON DELETE CASCADE ON UPDATE RESTRICT
);
CREATE TABLE live_chat_messages (
id uuid NOT NULL DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
channel_id uuid NOT NULL,
-- Provider connection that produced the message (capability target).
connection_id uuid NOT NULL,
provider text NOT NULL CHECK (btrim(provider) <> ''),
-- Provider's own message id; the dedupe key with workspace + provider.
provider_msg_id text NOT NULL CHECK (btrim(provider_msg_id) <> ''),
-- Author
author_handle text NOT NULL CHECK (btrim(author_handle) <> ''),
author_display_name text,
author_avatar_url text,
author_badges jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(author_badges) = 'array'),
author_color text NOT NULL CHECK (author_color <> ''),
body text NOT NULL CHECK (length(body) <= 5000),
-- Reply threading (provider message id of the parent, nullable).
reply_to text,
-- Hub ingress
seq bigint NOT NULL CHECK (seq > 0),
sent_at timestamptz NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
is_moderated boolean NOT NULL DEFAULT false,
source text NOT NULL DEFAULT 'live-chat' CHECK (source IN ('live-chat', 'social-reply', 'system')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (id, received_at),
UNIQUE (workspace_id, provider, provider_msg_id, received_at),
FOREIGN KEY (channel_id, workspace_id) REFERENCES virtual_channels (id, workspace_id)
ON DELETE CASCADE ON UPDATE RESTRICT,
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED
) PARTITION BY RANGE (received_at);
-- Precreate monthly partitions; administrators run this before a new month.
CREATE FUNCTION live_chat_ensure_month(p_month date) RETURNS void
LANGUAGE plpgsql SET search_path = public AS $$
DECLARE v_start timestamptz := date_trunc('month', p_month::timestamptz);
v_table text := 'live_chat_messages_' || to_char(v_start, 'YYYYMM');
BEGIN
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF live_chat_messages FOR VALUES FROM (%L) TO (%L)',
v_table, v_start, v_start + interval '1 month');
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', v_table);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', v_table);
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname='public' AND tablename=v_table AND policyname='tenant_isolation') THEN
EXECUTE format('CREATE POLICY tenant_isolation ON %I USING (workspace_id = NULLIF(current_setting(''app.workspace_id'', true), '''')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting(''app.workspace_id'', true), '''')::uuid)', v_table);
END IF;
END $$;
REVOKE ALL ON FUNCTION live_chat_ensure_month(date) FROM PUBLIC;
SELECT live_chat_ensure_month((current_date - interval '1 month')::date);
SELECT live_chat_ensure_month(current_date);
SELECT live_chat_ensure_month((current_date + interval '1 month')::date);
CREATE TABLE live_chat_messages_default PARTITION OF live_chat_messages DEFAULT;
ALTER TABLE live_chat_messages_default ENABLE ROW LEVEL SECURITY;
ALTER TABLE live_chat_messages_default FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON live_chat_messages_default
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE INDEX live_chat_messages_channel_seq ON live_chat_messages (workspace_id, channel_id, seq);
CREATE INDEX live_chat_messages_author ON live_chat_messages (workspace_id, channel_id, author_handle);
-- Global provider-message/event claim journal, mirroring
-- monitor_seen_events (ON CONFLICT DO NOTHING semantics come from the PK).
CREATE TABLE live_chat_seen_events (
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
provider text NOT NULL CHECK (btrim(provider) <> ''),
provider_event_id text NOT NULL CHECK (btrim(provider_event_id) <> ''),
seen_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, provider, provider_event_id)
);
CREATE TRIGGER trg_live_chat_seen_event_ws_immutable BEFORE UPDATE ON live_chat_seen_events
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
-- Devices: Opticast authenticates with a device token, not a session cookie
-- (§3.4). Epoch-gated: revoking bumps the epoch, tokens fail closed.
CREATE TABLE devices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT,
name text NOT NULL CHECK (btrim(name) <> '' AND length(name) <= 120),
scopes text[] NOT NULL DEFAULT '{livechat.view,stream.view}'::text[],
epoch bigint NOT NULL DEFAULT 1 CHECK (epoch >= 1),
authz_epoch bigint NOT NULL,
last_seen_at timestamptz,
revoked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, id)
);
CREATE INDEX devices_ws_user ON devices (workspace_id, user_id);
CREATE INDEX devices_ws_revoked ON devices (workspace_id) WHERE revoked_at IS NULL;
-- Single-use, TTL'd pairing codes. `redeemed_at` enforces single use; the
-- expiry is checked by the app (a code may expire unredeemed, and a purge of
-- expired rows is a plain DELETE).
CREATE TABLE device_pairings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT,
code text NOT NULL CHECK (code ~ '^[A-Z0-9]{4}-[A-Z0-9]{4}$'),
qr_payload text NOT NULL,
scopes text[] NOT NULL,
expires_at timestamptz NOT NULL,
redeemed_at timestamptz,
device_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (code),
UNIQUE (workspace_id, code),
FOREIGN KEY (device_id, workspace_id) REFERENCES devices (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED
);
CREATE INDEX device_pairings_ws_open ON device_pairings (workspace_id, code) WHERE redeemed_at IS NULL;
CREATE TRIGGER trg_device_ws_immutable BEFORE UPDATE ON devices FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_device_pairing_ws_immutable BEFORE UPDATE ON device_pairings FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_live_chat_message_ws_immutable BEFORE UPDATE ON live_chat_messages FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_live_chat_seq_ws_immutable BEFORE UPDATE ON live_chat_seq FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE live_chat_seq ENABLE ROW LEVEL SECURITY;
ALTER TABLE live_chat_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE live_chat_seen_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE devices ENABLE ROW LEVEL SECURITY;
ALTER TABLE device_pairings ENABLE ROW LEVEL SECURITY;
ALTER TABLE live_chat_seq FORCE ROW LEVEL SECURITY;
ALTER TABLE live_chat_messages FORCE ROW LEVEL SECURITY;
ALTER TABLE live_chat_seen_events FORCE ROW LEVEL SECURITY;
ALTER TABLE devices FORCE ROW LEVEL SECURITY;
ALTER TABLE device_pairings FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON live_chat_seq
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON live_chat_messages
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON live_chat_seen_events
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON devices
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON device_pairings
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON live_chat_seq, live_chat_messages, live_chat_seen_events,
devices, device_pairings TO sp_app;
-- Retention (follow-on, noted here so the partition/retention row from the
-- work-breakdown finds its anchor): pruning live_chat_seen_events older than
-- the journal window, mirroring monitor_prune_seen_events from 012.
CREATE FUNCTION live_chat_prune_seen_events(p_before timestamptz) RETURNS integer
LANGUAGE sql SECURITY INVOKER SET search_path = public AS $$
WITH removed AS (
DELETE FROM live_chat_seen_events WHERE seen_at < p_before RETURNING 1
)
SELECT count(*) FROM removed
$$;
REVOKE ALL ON FUNCTION live_chat_prune_seen_events(timestamptz) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION live_chat_prune_seen_events(timestamptz) TO sp_app;
-- Resolves only a valid short-lived bearer pairing code; no tenant rows exposed.
CREATE FUNCTION device_pairing_workspace(p_code text) RETURNS uuid
LANGUAGE sql SECURITY DEFINER SET search_path = public AS $$
SELECT workspace_id FROM device_pairings WHERE code=p_code AND expires_at > now()
$$;
REVOKE ALL ON FUNCTION device_pairing_workspace(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION device_pairing_workspace(text) TO sp_app;
-- Tenant-scoped retention for messages and their dedupe claims.
CREATE FUNCTION live_chat_prune(p_workspace uuid, p_before timestamptz) RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
DECLARE v_count integer;
BEGIN
PERFORM fn_assert_tenant(p_workspace);
DELETE FROM live_chat_messages WHERE workspace_id=p_workspace AND received_at < p_before;
GET DIAGNOSTICS v_count = ROW_COUNT;
DELETE FROM live_chat_seen_events WHERE workspace_id=p_workspace AND seen_at < p_before;
DELETE FROM device_pairings WHERE workspace_id=p_workspace AND expires_at < p_before;
RETURN v_count;
END $$;
REVOKE ALL ON FUNCTION live_chat_prune(uuid,timestamptz) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION live_chat_prune(uuid,timestamptz) TO sp_app;
CREATE TABLE live_chat_requests (
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
route text NOT NULL,
request_key text NOT NULL,
request_hash text NOT NULL,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','complete','failed')),
response jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id,user_id,route,request_key)
);
ALTER TABLE live_chat_requests ENABLE ROW LEVEL SECURITY;
ALTER TABLE live_chat_requests FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON live_chat_requests
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE TRIGGER trg_live_chat_request_ws_immutable BEFORE UPDATE ON live_chat_requests
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
GRANT SELECT,INSERT,UPDATE,DELETE ON live_chat_requests TO sp_app;

View file

@ -0,0 +1,88 @@
-- 015: WP15 Telegram live channel archive. All identities are tenant pinned.
CREATE TABLE feed_subscriptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
type text NOT NULL CHECK (type = 'telegram_channel'),
connection_id uuid NOT NULL,
channel_id text NOT NULL CHECK (btrim(channel_id) <> ''),
health text NOT NULL DEFAULT 'active' CHECK (health IN ('active', 'bot_removed', 'admin_required')),
health_checked_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, connection_id, channel_id),
UNIQUE (id, workspace_id),
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT
);
CREATE INDEX feed_subscriptions_channel ON feed_subscriptions (workspace_id, channel_id);
CREATE TABLE archived_feed_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
remote_ref text NOT NULL CHECK (remote_ref ~ '^(-?[0-9]+|@[A-Za-z0-9_]+):[0-9]+$'),
channel_id text NOT NULL,
message_id bigint NOT NULL CHECK (message_id > 0),
media_group_id text,
text_content text NOT NULL DEFAULT '',
content_hash text NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'),
revision integer NOT NULL DEFAULT 1 CHECK (revision > 0),
media_refs jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(media_refs) = 'array'),
attribution jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(attribution) = 'object'),
protected_content boolean NOT NULL DEFAULT false,
posted_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, remote_ref),
UNIQUE (id, workspace_id),
CHECK (remote_ref = channel_id || ':' || message_id::text)
);
CREATE INDEX archived_feed_items_cursor ON archived_feed_items (workspace_id, posted_at DESC, id DESC);
CREATE TABLE archived_feed_revisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
item_id uuid NOT NULL,
revision integer NOT NULL CHECK (revision > 0),
content_hash text NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'),
text_content text NOT NULL,
media_refs jsonb NOT NULL CHECK (jsonb_typeof(media_refs) = 'array'),
attribution jsonb NOT NULL CHECK (jsonb_typeof(attribution) = 'object'),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, item_id, revision),
FOREIGN KEY (item_id, workspace_id) REFERENCES archived_feed_items (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT
);
-- drafts predates tenant-pinned references.
ALTER TABLE drafts ADD CONSTRAINT drafts_id_workspace_key UNIQUE (id, workspace_id);
CREATE TABLE archived_feed_drafts (
workspace_id uuid NOT NULL,
item_id uuid NOT NULL,
draft_id uuid NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, item_id, draft_id),
FOREIGN KEY (item_id, workspace_id) REFERENCES archived_feed_items (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT,
FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT
);
CREATE TRIGGER trg_feed_subscriptions_ws_immutable BEFORE UPDATE ON feed_subscriptions
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_archived_feed_items_ws_immutable BEFORE UPDATE ON archived_feed_items
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_archived_feed_revisions_ws_immutable BEFORE UPDATE ON archived_feed_revisions
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_archived_feed_drafts_ws_immutable BEFORE UPDATE ON archived_feed_drafts
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE feed_subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE archived_feed_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE archived_feed_revisions ENABLE ROW LEVEL SECURITY;
ALTER TABLE archived_feed_drafts ENABLE ROW LEVEL SECURITY;
ALTER TABLE feed_subscriptions FORCE ROW LEVEL SECURITY;
ALTER TABLE archived_feed_items FORCE ROW LEVEL SECURITY;
ALTER TABLE archived_feed_revisions FORCE ROW LEVEL SECURITY;
ALTER TABLE archived_feed_drafts FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON feed_subscriptions USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON archived_feed_items USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON archived_feed_revisions USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON archived_feed_drafts USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON feed_subscriptions, archived_feed_items, archived_feed_revisions, archived_feed_drafts TO sp_app;

View file

@ -0,0 +1,20 @@
-- 016: username-based first-party auth.
-- Usernames are the login identity (external_id = local:<username>); email is
-- optional metadata for future password recovery. Existing email-keyed users
-- get a username derived from their email local-part (or display name).
ALTER TABLE users ADD COLUMN username text;
UPDATE users SET username = COALESCE(
NULLIF(regexp_replace(lower(split_part(coalesce(email, ''), '@', 1)), '[^a-z0-9_.-]', '', 'g'), ''),
NULLIF(regexp_replace(lower(display_name), '[^a-z0-9_.-]', '', 'g'), ''),
'member'
);
-- Deterministic dedupe: colliding candidates get a short id suffix.
UPDATE users u SET username = u.username || '_' || substr(u.id::text, 1, 4)
WHERE EXISTS (SELECT 1 FROM users v WHERE v.username = u.username AND v.id < u.id);
ALTER TABLE users ALTER COLUMN username SET NOT NULL;
CREATE UNIQUE INDEX users_username_key ON users (username);
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
GRANT SELECT, INSERT, UPDATE, DELETE ON users TO sp_app;

View file

@ -0,0 +1,64 @@
-- 017: multi-use, expiring workspace invite links (Telegram-style).
-- One link, many claimants: anyone holding the token may join with the link's
-- role until the link expires, is revoked, or exhausts max_uses (null =
-- unlimited until expiry). Claimers arrive unauthenticated, so the claim path
-- is the SECURITY DEFINER analogue of login (see invite_link_for_claim below):
-- workspace_invite_links is FORCE RLS and no tenant context exists pre-join.
CREATE TABLE workspace_invite_links (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
role text NOT NULL,
token text NOT NULL UNIQUE,
created_by uuid NOT NULL REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
max_uses int CHECK (max_uses IS NULL OR max_uses > 0),
use_count int NOT NULL DEFAULT 0 CHECK (use_count >= 0),
revoked_at timestamptz
);
CREATE TRIGGER trg_invite_link_ws_immutable BEFORE UPDATE ON workspace_invite_links
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
-- Same fail-closed tenant isolation as every tenant table (001/006 style):
-- no app.workspace_id => NULL => no rows. FORCE binds sp_app too.
ALTER TABLE workspace_invite_links ENABLE ROW LEVEL SECURITY;
ALTER TABLE workspace_invite_links FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON workspace_invite_links
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON workspace_invite_links TO sp_app;
-- Claim runs with NO tenant context (the claimer has no membership yet), and
-- the table is FORCE RLS: ordinary SELECTs see zero rows. Like
-- auth_user_memberships (007) and publishing_schedule_workspace (006), this
-- narrow SECURITY DEFINER lookup exposes exactly one row keyed by the secret
-- token — workspace_id discovery only; the caller enters the tenant context
-- afterwards and every subsequent statement is RLS-bound as sp_app.
CREATE FUNCTION invite_link_for_claim(p_token text)
RETURNS TABLE(id uuid, workspace_id uuid, role text, expires_at timestamptz,
max_uses int, use_count int, revoked_at timestamptz)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT l.id, l.workspace_id, l.role, l.expires_at, l.max_uses, l.use_count, l.revoked_at
FROM workspace_invite_links l
WHERE l.token = p_token
$$;
REVOKE ALL ON FUNCTION invite_link_for_claim(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION invite_link_for_claim(text) TO sp_app;
-- Token lookup at claim time: point lookup served by the backing index of the
-- inline UNIQUE constraint on token (auto-named workspace_invite_links_token_key).
-- Statement for documentation/parity with 016's users_username_key — do NOT run twice:
-- CREATE UNIQUE INDEX workspace_invite_links_token_key ON workspace_invite_links (token);
-- Workspace listing (admin GET /team/invite-links), newest first:
CREATE INDEX workspace_invite_links_ws_created
ON workspace_invite_links (workspace_id, created_at DESC);
-- Expiry sweep / active-link diagnostics, mirroring the partial-index style of
-- audit_log (001) and scheduled_publications_due (006):
CREATE INDEX workspace_invite_links_expiry
ON workspace_invite_links (expires_at) WHERE revoked_at IS NULL;

View file

@ -0,0 +1,485 @@
-- 018: WP3 publishing execution spine (fences, attempt journal, receipts).
-- Preserves 006: same drafts / scheduled_publications / publications tables,
-- same PKs, UNIQUE(draft_id) on schedules and global idempotency_key
-- uniqueness. Adds the scheduling principal, revision/approval proof,
-- credential generation/revocation, execution generation and the durable
-- attempt journal the scheduler claims, fences and finalizes against.
--
-- The current runner (db/migrate.ts) wraps each file in ONE transaction, so
-- this file is written to be transactional end to end: no CREATE INDEX
-- CONCURRENTLY, bounded by lock_timeout, preflight fails closed on corrupt
-- tenant references instead of silently reparenting them. Quiesce the
-- publishing worker for the upgrade (plan §2 "Existing data rollout").
SET LOCAL lock_timeout = '5s';
-- ---------------------------------------------------------------------------
-- Preflight: refuse to upgrade data that the tenant-composite FKs below would
-- reject anyway, with an operator-readable reason. Nothing is reparented.
-- ---------------------------------------------------------------------------
DO $$
DECLARE v_bad bigint;
BEGIN
SELECT count(*) INTO v_bad FROM scheduled_publications sp
WHERE NOT EXISTS (SELECT 1 FROM drafts d WHERE d.id = sp.draft_id AND d.workspace_id = sp.workspace_id)
OR NOT EXISTS (SELECT 1 FROM connections c WHERE c.id = sp.connection_id AND c.workspace_id = sp.workspace_id);
IF v_bad > 0 THEN
RAISE EXCEPTION '018 preflight: % scheduled_publications rows reference a draft/connection in another workspace', v_bad;
END IF;
SELECT count(*) INTO v_bad FROM publications p
WHERE NOT EXISTS (SELECT 1 FROM drafts d WHERE d.id = p.draft_id AND d.workspace_id = p.workspace_id)
OR NOT EXISTS (SELECT 1 FROM connections c WHERE c.id = p.connection_id AND c.workspace_id = p.workspace_id);
IF v_bad > 0 THEN
RAISE EXCEPTION '018 preflight: % publications rows reference a draft/connection in another workspace', v_bad;
END IF;
SELECT count(*) INTO v_bad FROM credentials cr
WHERE cr.connection_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM connections c WHERE c.id = cr.connection_id AND c.workspace_id = cr.workspace_id);
IF v_bad > 0 THEN
RAISE EXCEPTION '018 preflight: % credentials rows reference a connection in another workspace', v_bad;
END IF;
SELECT count(*) INTO v_bad FROM (
SELECT 1 FROM credentials WHERE kind = 'connector-oauth' AND connection_id IS NOT NULL
GROUP BY workspace_id, connection_id HAVING count(*) > 1
) dup;
IF v_bad > 0 THEN
RAISE EXCEPTION '018 preflight: % connections have more than one connector-oauth credential; reconcile before upgrading', v_bad;
END IF;
END $$;
-- ---------------------------------------------------------------------------
-- Credentials: generation + revocation. key_version is NOT a token
-- generation (APV1 sealing writes version 1 on every rotation).
-- ---------------------------------------------------------------------------
ALTER TABLE credentials
ADD COLUMN generation bigint NOT NULL DEFAULT 1 CHECK (generation > 0),
ADD COLUMN revoked_at timestamptz;
ALTER TABLE credentials ADD CONSTRAINT credentials_id_workspace_key UNIQUE (id, workspace_id);
-- One live connector credential per connection; readers stop using LIMIT 1.
CREATE UNIQUE INDEX credentials_connector_oauth_unique
ON credentials (workspace_id, connection_id) WHERE kind = 'connector-oauth';
-- generation is DB-owned: any secret-material or lifecycle change rotates it,
-- and callers cannot rewind it.
CREATE FUNCTION credentials_bump_generation() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.ciphertext IS DISTINCT FROM OLD.ciphertext
OR NEW.key_version IS DISTINCT FROM OLD.key_version
OR NEW.rotated_at IS DISTINCT FROM OLD.rotated_at
OR NEW.revoked_at IS DISTINCT FROM OLD.revoked_at THEN
NEW.generation := OLD.generation + 1;
ELSE
NEW.generation := OLD.generation;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_cred_generation BEFORE UPDATE ON credentials
FOR EACH ROW EXECUTE FUNCTION credentials_bump_generation();
-- Credentials are an execution-authorization input: changes bump the epoch
-- exactly like memberships (reuses bump_authz_epoch, 001/005).
CREATE TRIGGER trg_cred_bump_epoch
AFTER INSERT OR DELETE OR UPDATE OF ciphertext, key_version, rotated_at, revoked_at ON credentials
FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch();
-- ---------------------------------------------------------------------------
-- Connections: tombstones keep journal evidence intact after removal.
-- ---------------------------------------------------------------------------
ALTER TABLE connections ADD COLUMN disconnected_at timestamptz;
CREATE TRIGGER trg_conn_disconnect_bump_epoch AFTER UPDATE OF disconnected_at ON connections
FOR EACH ROW WHEN (NEW.disconnected_at IS DISTINCT FROM OLD.disconnected_at)
EXECUTE FUNCTION bump_authz_epoch();
-- Group sharing gates userCanUseConnection, so it bumps the epoch too (008
-- only covered group_members).
CREATE TRIGGER trg_group_connection_bump_epoch AFTER INSERT OR UPDATE OR DELETE ON group_connections
FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch();
-- role_permissions is global. Every workspace's Owner/Admin/... roles come
-- from it, so a change bumps all workspaces once per statement, locked in
-- UUID order to avoid lock-order inversions between concurrent bumps.
CREATE FUNCTION bump_all_authz_epochs() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM 1 FROM workspaces ORDER BY id FOR UPDATE;
UPDATE workspaces SET authz_epoch = authz_epoch + 1;
RETURN NULL;
END $$;
CREATE TRIGGER trg_role_permissions_bump_epoch AFTER INSERT OR UPDATE OR DELETE ON role_permissions
FOR EACH STATEMENT EXECUTE FUNCTION bump_all_authz_epochs();
-- ---------------------------------------------------------------------------
-- Drafts: revision + approval proof + business attempt counter.
-- ---------------------------------------------------------------------------
ALTER TABLE drafts
ADD COLUMN revision bigint NOT NULL DEFAULT 1 CHECK (revision > 0),
ADD COLUMN approved_revision bigint,
ADD COLUMN approved_hash text,
ADD COLUMN approved_at timestamptz,
ADD COLUMN review_reason text,
ADD COLUMN publication_attempt bigint NOT NULL DEFAULT 0 CHECK (publication_attempt >= 0);
-- drafts_id_workspace_key (id, workspace_id) already exists from 015.
-- ---------------------------------------------------------------------------
-- Scheduled publications: principal/fence snapshot + execution generation.
-- ---------------------------------------------------------------------------
ALTER TABLE scheduled_publications
ADD COLUMN created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN scheduled_by_user_id uuid REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
ADD COLUMN required_permission text
CHECK (required_permission IN ('publication.schedule', 'publication.publish')),
ADD COLUMN authorized_epoch bigint,
ADD COLUMN approved_revision bigint,
ADD COLUMN post_hash text,
ADD COLUMN credential_id uuid,
ADD COLUMN credential_generation bigint,
ADD COLUMN generation bigint NOT NULL DEFAULT 1 CHECK (generation > 0),
ADD COLUMN attempt_no bigint NOT NULL DEFAULT 1 CHECK (attempt_no > 0),
ADD COLUMN execution_version smallint NOT NULL DEFAULT 0 CHECK (execution_version IN (0, 1)),
ADD COLUMN timezone text NOT NULL DEFAULT 'UTC',
ADD COLUMN local_publish_at timestamp,
ADD COLUMN tzdata_version text,
ADD COLUMN next_attempt_at timestamptz,
ADD COLUMN retry_count int NOT NULL DEFAULT 0 CHECK (retry_count >= 0),
ADD COLUMN error_code text;
ALTER TABLE scheduled_publications DROP CONSTRAINT scheduled_publications_state_check;
ALTER TABLE scheduled_publications ADD CONSTRAINT scheduled_publications_state_check
CHECK (state IN ('pending', 'in_flight', 'done', 'failed', 'needs_reauthorization', 'cancelled'));
-- Legacy rows: timestamps from the draft, due time from publish_at, UTC zone.
UPDATE scheduled_publications sp
SET created_at = d.created_at, updated_at = d.updated_at, next_attempt_at = sp.publish_at
FROM drafts d
WHERE d.id = sp.draft_id AND d.workspace_id = sp.workspace_id;
-- Existing publications are authoritative: their schedule is done, draft published.
UPDATE scheduled_publications sp SET state = 'done', error = NULL, error_code = NULL
WHERE EXISTS (SELECT 1 FROM publications p
WHERE p.workspace_id = sp.workspace_id
AND (p.idempotency_key = sp.idempotency_key OR p.draft_id = sp.draft_id));
UPDATE drafts d SET state = 'published'
WHERE d.state <> 'published'
AND EXISTS (SELECT 1 FROM publications p WHERE p.workspace_id = d.workspace_id AND p.draft_id = d.id);
-- No recorded principal: never infer one from author/audit history.
UPDATE scheduled_publications SET state = 'needs_reauthorization', error_code = 'LEGACY_AUTHORIZATION_MISSING'
WHERE state = 'pending';
-- In flight without a publication: the send may have happened.
UPDATE scheduled_publications SET state = 'failed', error_code = 'PUBLISH_OUTCOME_UNKNOWN'
WHERE state = 'in_flight';
-- Upgraded (execution_version = 1) rows must carry every execution fence
-- while executable or done. Legacy rows (0) are readable, never executable:
-- every claim requires execution_version = 1.
ALTER TABLE scheduled_publications ADD CONSTRAINT scheduled_publications_fences_check CHECK (
execution_version = 0
OR state IN ('failed', 'needs_reauthorization', 'cancelled')
OR (scheduled_by_user_id IS NOT NULL AND required_permission IS NOT NULL
AND authorized_epoch IS NOT NULL AND approved_revision IS NOT NULL
AND post_hash IS NOT NULL AND credential_id IS NOT NULL
AND credential_generation IS NOT NULL AND next_attempt_at IS NOT NULL)
);
ALTER TABLE scheduled_publications
ADD CONSTRAINT scheduled_publications_id_workspace_key UNIQUE (id, workspace_id);
-- Tenant-composite replacements for 006's single-column FKs (same ON DELETE
-- semantics; journal tables below add NO ACTION guards).
ALTER TABLE scheduled_publications
DROP CONSTRAINT scheduled_publications_draft_id_fkey,
DROP CONSTRAINT scheduled_publications_connection_id_fkey,
ADD CONSTRAINT scheduled_publications_draft_ws_fkey FOREIGN KEY (draft_id, workspace_id)
REFERENCES drafts (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT,
ADD CONSTRAINT scheduled_publications_connection_ws_fkey FOREIGN KEY (connection_id, workspace_id)
REFERENCES connections (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT,
ADD CONSTRAINT scheduled_publications_credential_ws_fkey FOREIGN KEY (credential_id, workspace_id)
REFERENCES credentials (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT;
ALTER TABLE publications
DROP CONSTRAINT publications_draft_id_fkey,
DROP CONSTRAINT publications_connection_id_fkey,
ADD CONSTRAINT publications_draft_ws_fkey FOREIGN KEY (draft_id, workspace_id)
REFERENCES drafts (id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT,
ADD CONSTRAINT publications_connection_ws_fkey FOREIGN KEY (connection_id, workspace_id)
REFERENCES connections (id, workspace_id) ON DELETE NO ACTION ON UPDATE RESTRICT;
-- Schedules: DB-owned updated_at; generation and execution_version only move forward.
CREATE FUNCTION scheduled_publications_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.generation < OLD.generation THEN
RAISE EXCEPTION 'schedule generation cannot decrease' USING ERRCODE = '23514';
END IF;
IF NEW.execution_version < OLD.execution_version THEN
RAISE EXCEPTION 'schedule execution_version cannot decrease' USING ERRCODE = '23514';
END IF;
NEW.updated_at := now();
RETURN NEW;
END $$;
CREATE TRIGGER trg_scheduled_pub_guard BEFORE UPDATE ON scheduled_publications
FOR EACH ROW EXECUTE FUNCTION scheduled_publications_guard();
-- ---------------------------------------------------------------------------
-- Execution journal: one row per business attempt. Snapshot columns are
-- immutable; mutable columns carry ownership (owner_token/claim_epoch/lease)
-- and the dispatch marker.
-- ---------------------------------------------------------------------------
CREATE TABLE publishing_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
schedule_id uuid NOT NULL,
draft_id uuid NOT NULL,
connection_id uuid NOT NULL,
attempt_no bigint NOT NULL CHECK (attempt_no > 0),
idempotency_key text NOT NULL CHECK (btrim(idempotency_key) <> ''),
schedule_generation bigint NOT NULL CHECK (schedule_generation > 0),
post_snapshot jsonb NOT NULL CHECK (jsonb_typeof(post_snapshot) = 'object'),
post_hash text NOT NULL CHECK (btrim(post_hash) <> ''),
scheduled_by_user_id uuid NOT NULL REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
required_permission text NOT NULL
CHECK (required_permission IN ('publication.schedule', 'publication.publish')),
approved_revision bigint NOT NULL CHECK (approved_revision > 0),
reviewer_user_id uuid NOT NULL REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
credential_id uuid NOT NULL,
credential_generation bigint NOT NULL CHECK (credential_generation > 0),
authorized_epoch bigint NOT NULL,
dispatch_count int NOT NULL DEFAULT 0 CHECK (dispatch_count >= 0 AND dispatch_count <= 5),
status text NOT NULL DEFAULT 'ready'
CHECK (status IN ('ready', 'in_progress', 'completed', 'failed', 'unknown', 'abandoned')),
claim_epoch bigint NOT NULL DEFAULT 0 CHECK (claim_epoch >= 0),
owner_token uuid,
lease_until timestamptz,
dispatch_started_at timestamptz,
provider_retry_deadline timestamptz,
error_code text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Ownership exists exactly while in progress.
CHECK ((status = 'in_progress') = (owner_token IS NOT NULL AND lease_until IS NOT NULL)),
CHECK (status = 'in_progress' OR (owner_token IS NULL AND lease_until IS NULL)),
-- A dispatch marker implies at least one counted dispatch.
CHECK (dispatch_started_at IS NULL OR dispatch_count > 0),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, draft_id, attempt_no),
UNIQUE (workspace_id, idempotency_key),
FOREIGN KEY (schedule_id, workspace_id) REFERENCES scheduled_publications (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT,
FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT,
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT,
FOREIGN KEY (credential_id, workspace_id) REFERENCES credentials (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
-- Snapshot immutability, terminal statuses, and DB-owned claim epoch: every
-- new owner token advances claim_epoch by exactly one, so a deposed owner's
-- (owner_token, claim_epoch) pair can never match again.
CREATE FUNCTION publishing_attempts_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF (NEW.schedule_id, NEW.draft_id, NEW.connection_id, NEW.attempt_no, NEW.idempotency_key,
NEW.schedule_generation, NEW.post_snapshot, NEW.post_hash, NEW.scheduled_by_user_id,
NEW.required_permission, NEW.approved_revision, NEW.reviewer_user_id,
NEW.credential_id, NEW.credential_generation, NEW.created_at)
IS DISTINCT FROM
(OLD.schedule_id, OLD.draft_id, OLD.connection_id, OLD.attempt_no, OLD.idempotency_key,
OLD.schedule_generation, OLD.post_snapshot, OLD.post_hash, OLD.scheduled_by_user_id,
OLD.required_permission, OLD.approved_revision, OLD.reviewer_user_id,
OLD.credential_id, OLD.credential_generation, OLD.created_at) THEN
RAISE EXCEPTION 'publishing attempt snapshot is immutable' USING ERRCODE = '23514';
END IF;
IF OLD.status IN ('completed', 'abandoned') AND NEW.status IS DISTINCT FROM OLD.status THEN
RAISE EXCEPTION 'publishing attempt % is terminal', OLD.status USING ERRCODE = '23514';
END IF;
IF NEW.dispatch_count < OLD.dispatch_count THEN
RAISE EXCEPTION 'dispatch_count cannot decrease' USING ERRCODE = '23514';
END IF;
IF NEW.provider_retry_deadline IS DISTINCT FROM OLD.provider_retry_deadline
AND OLD.provider_retry_deadline IS NOT NULL THEN
RAISE EXCEPTION 'provider_retry_deadline is fixed at first dispatch' USING ERRCODE = '23514';
END IF;
IF NEW.owner_token IS NOT NULL AND NEW.owner_token IS DISTINCT FROM OLD.owner_token THEN
NEW.claim_epoch := OLD.claim_epoch + 1;
ELSE
NEW.claim_epoch := OLD.claim_epoch;
END IF;
NEW.updated_at := now();
RETURN NEW;
END $$;
CREATE TRIGGER trg_publishing_attempt_ws_immutable BEFORE UPDATE ON publishing_attempts
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_publishing_attempt_guard BEFORE UPDATE ON publishing_attempts
FOR EACH ROW EXECUTE FUNCTION publishing_attempts_guard();
-- Durable provider evidence, separate from the attempt so it survives activity
-- timeouts and stale-owner completion refusal. Append-only.
CREATE TABLE publishing_receipts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
attempt_id uuid NOT NULL,
remote_id text NOT NULL CHECK (btrim(remote_id) <> '' AND length(remote_id) <= 512),
remote_url text CHECK (remote_url IS NULL OR length(remote_url) <= 2048),
published_at timestamptz,
provider_metadata jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(provider_metadata) = 'object'),
received_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, attempt_id),
FOREIGN KEY (attempt_id, workspace_id) REFERENCES publishing_attempts (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
-- Receipts are never rewritten. Deletion is only the workspace purge cascade
-- (the parent workspace row is already gone when the cascade reaches here).
CREATE FUNCTION publishing_receipts_append_only() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
IF EXISTS (SELECT 1 FROM workspaces w WHERE w.id = OLD.workspace_id) THEN
RAISE EXCEPTION 'publishing receipts are append-only' USING ERRCODE = '23514';
END IF;
RETURN OLD;
END IF;
RAISE EXCEPTION 'publishing receipts are append-only' USING ERRCODE = '23514';
END $$;
CREATE TRIGGER trg_publishing_receipt_append_only BEFORE UPDATE OR DELETE ON publishing_receipts
FOR EACH ROW EXECUTE FUNCTION publishing_receipts_append_only();
-- Draft-creation request idempotency, committed atomically with the draft.
CREATE TABLE publishing_requests (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE RESTRICT,
route text NOT NULL CHECK (btrim(route) <> ''),
request_key uuid NOT NULL,
request_hash text NOT NULL CHECK (btrim(request_hash) <> ''),
response jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, user_id, route, request_key)
);
CREATE TRIGGER trg_publishing_request_ws_immutable BEFORE UPDATE ON publishing_requests
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
-- ---------------------------------------------------------------------------
-- Draft fence: content changes rotate the revision and void approval; they
-- are refused while a dispatch is active or its outcome is unknown.
-- ---------------------------------------------------------------------------
CREATE FUNCTION drafts_revision_fence() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.publication_attempt < OLD.publication_attempt THEN
RAISE EXCEPTION 'publication_attempt cannot decrease' USING ERRCODE = '23514';
END IF;
IF NEW.post IS DISTINCT FROM OLD.post THEN
IF EXISTS (SELECT 1 FROM publishing_attempts a
WHERE a.workspace_id = OLD.workspace_id AND a.draft_id = OLD.id
AND (a.status = 'unknown'
OR (a.status = 'in_progress' AND a.dispatch_started_at IS NOT NULL))) THEN
RAISE EXCEPTION 'draft content is locked while a dispatch is unsettled' USING ERRCODE = '55000';
END IF;
NEW.revision := OLD.revision + 1;
NEW.approved_revision := NULL;
NEW.approved_hash := NULL;
NEW.approved_at := NULL;
NEW.reviewer_user_id := NULL;
ELSE
NEW.revision := OLD.revision;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_draft_revision_fence BEFORE UPDATE ON drafts
FOR EACH ROW EXECUTE FUNCTION drafts_revision_fence();
-- ---------------------------------------------------------------------------
-- RLS + grants for the new tenant tables (001/006/017 style).
-- ---------------------------------------------------------------------------
ALTER TABLE publishing_attempts ENABLE ROW LEVEL SECURITY;
ALTER TABLE publishing_receipts ENABLE ROW LEVEL SECURITY;
ALTER TABLE publishing_requests ENABLE ROW LEVEL SECURITY;
ALTER TABLE publishing_attempts FORCE ROW LEVEL SECURITY;
ALTER TABLE publishing_receipts FORCE ROW LEVEL SECURITY;
ALTER TABLE publishing_requests FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON publishing_attempts
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON publishing_receipts
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON publishing_requests
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
-- 003's default privileges grant all four verbs; narrow them explicitly:
-- the journal is never deleted by the app, receipts are append-only.
GRANT SELECT, INSERT, UPDATE ON publishing_attempts TO sp_app;
REVOKE DELETE ON publishing_attempts FROM sp_app;
GRANT SELECT, INSERT ON publishing_receipts TO sp_app;
REVOKE UPDATE, DELETE ON publishing_receipts FROM sp_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON publishing_requests TO sp_app;
-- ---------------------------------------------------------------------------
-- Indexes: keyset lists (no OFFSET), due/recovery discovery, FK coverage.
-- ---------------------------------------------------------------------------
CREATE INDEX drafts_ws_updated ON drafts (workspace_id, updated_at DESC, id DESC);
CREATE INDEX drafts_ws_state_updated ON drafts (workspace_id, state, updated_at DESC, id DESC);
CREATE INDEX scheduled_publications_ws_publish ON scheduled_publications (workspace_id, publish_at DESC, id DESC);
CREATE INDEX scheduled_publications_ws_conn_publish
ON scheduled_publications (workspace_id, connection_id, publish_at DESC, id DESC);
CREATE INDEX scheduled_publications_next_due ON scheduled_publications (next_attempt_at, id)
WHERE state = 'pending';
CREATE INDEX scheduled_publications_ws_credential ON scheduled_publications (workspace_id, credential_id)
WHERE credential_id IS NOT NULL;
CREATE INDEX publications_ws_published ON publications (workspace_id, published_at DESC, id DESC);
CREATE INDEX publications_ws_conn_published
ON publications (workspace_id, connection_id, published_at DESC, id DESC);
CREATE INDEX publishing_attempts_ws_schedule ON publishing_attempts (workspace_id, schedule_id, attempt_no DESC);
CREATE INDEX publishing_attempts_ws_connection ON publishing_attempts (workspace_id, connection_id);
CREATE INDEX publishing_attempts_ws_credential ON publishing_attempts (workspace_id, credential_id);
CREATE INDEX publishing_attempts_lease_recovery ON publishing_attempts (lease_until, id)
WHERE status = 'in_progress';
-- ---------------------------------------------------------------------------
-- Discovery (replaces 006). Identifiers/times only, bounded, fixed
-- search_path, PUBLIC revoked. Callers enter withTenant(workspace_id) and
-- re-verify key/generation ownership under RLS.
-- ---------------------------------------------------------------------------
DROP FUNCTION publishing_due_schedules();
CREATE FUNCTION publishing_due_schedules(p_limit integer DEFAULT 100)
RETURNS TABLE(workspace_id uuid, schedule_id uuid, idempotency_key text, generation bigint, publish_at timestamptz)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT due.workspace_id, due.schedule_id, due.idempotency_key, due.generation, due.publish_at
FROM (
SELECT sp.workspace_id, sp.id AS schedule_id, sp.idempotency_key, sp.generation,
sp.publish_at, sp.next_attempt_at AS due_at
FROM scheduled_publications sp
WHERE sp.state = 'pending' AND sp.execution_version = 1 AND sp.next_attempt_at <= now()
UNION ALL
SELECT a.workspace_id, a.schedule_id, a.idempotency_key, a.schedule_generation,
sp.publish_at, a.lease_until AS due_at
FROM publishing_attempts a
JOIN scheduled_publications sp ON sp.id = a.schedule_id AND sp.workspace_id = a.workspace_id
WHERE a.status = 'in_progress' AND a.lease_until < now()
) due
ORDER BY due.due_at, due.schedule_id
LIMIT LEAST(GREATEST(COALESCE(p_limit, 100), 1), 100)
$$;
-- Attempt journal first (every executable key), then legacy schedules/publications.
CREATE OR REPLACE FUNCTION publishing_schedule_workspace(p_idempotency_key text) RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT COALESCE(
(SELECT a.workspace_id FROM publishing_attempts a WHERE a.idempotency_key = p_idempotency_key LIMIT 1),
(SELECT sp.workspace_id FROM scheduled_publications sp WHERE sp.idempotency_key = p_idempotency_key),
(SELECT p.workspace_id FROM publications p WHERE p.idempotency_key = p_idempotency_key)
)
$$;
REVOKE ALL ON FUNCTION publishing_due_schedules(integer) FROM PUBLIC;
REVOKE ALL ON FUNCTION publishing_schedule_workspace(text) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION publishing_due_schedules(integer), publishing_schedule_workspace(text) TO sp_app;

View file

@ -0,0 +1,103 @@
-- 018 is retained unchanged: local anp_publishing_it records its application.
-- These corrections use independently tracked resumable stages.
-- wp3:phase expand transaction
SET LOCAL lock_timeout = '5s';
ALTER TABLE publishing_attempts ADD COLUMN first_dispatch_started_at timestamptz;
CREATE OR REPLACE FUNCTION publishing_attempts_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF (NEW.schedule_id, NEW.draft_id, NEW.connection_id, NEW.attempt_no, NEW.idempotency_key,
NEW.schedule_generation, NEW.post_snapshot, NEW.post_hash, NEW.scheduled_by_user_id,
NEW.required_permission, NEW.approved_revision, NEW.reviewer_user_id,
NEW.credential_id, NEW.credential_generation, NEW.created_at)
IS DISTINCT FROM
(OLD.schedule_id, OLD.draft_id, OLD.connection_id, OLD.attempt_no, OLD.idempotency_key,
OLD.schedule_generation, OLD.post_snapshot, OLD.post_hash, OLD.scheduled_by_user_id,
OLD.required_permission, OLD.approved_revision, OLD.reviewer_user_id,
OLD.credential_id, OLD.credential_generation, OLD.created_at) THEN
RAISE EXCEPTION 'publishing attempt snapshot is immutable' USING ERRCODE = '23514';
END IF;
IF OLD.status IN ('completed', 'abandoned') AND NEW.status IS DISTINCT FROM OLD.status THEN
RAISE EXCEPTION 'publishing attempt % is terminal', OLD.status USING ERRCODE = '23514';
END IF;
IF OLD.first_dispatch_started_at IS NOT NULL AND NEW.first_dispatch_started_at IS DISTINCT FROM OLD.first_dispatch_started_at THEN
RAISE EXCEPTION 'first dispatch time is immutable' USING ERRCODE = '23514';
END IF;
IF NEW.dispatch_count < OLD.dispatch_count THEN
RAISE EXCEPTION 'dispatch_count cannot decrease' USING ERRCODE = '23514';
END IF;
IF NEW.provider_retry_deadline IS DISTINCT FROM OLD.provider_retry_deadline
AND OLD.provider_retry_deadline IS NOT NULL THEN
RAISE EXCEPTION 'provider_retry_deadline is fixed at first dispatch' USING ERRCODE = '23514';
END IF;
IF NEW.owner_token IS NOT NULL AND NEW.owner_token IS DISTINCT FROM OLD.owner_token THEN
NEW.claim_epoch := OLD.claim_epoch + 1;
ELSE
NEW.claim_epoch := OLD.claim_epoch;
END IF;
NEW.updated_at := now();
RETURN NEW;
END $$;
CREATE OR REPLACE FUNCTION drafts_revision_fence() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.publication_attempt < OLD.publication_attempt THEN
RAISE EXCEPTION 'publication_attempt cannot decrease' USING ERRCODE = '23514';
END IF;
IF NEW.post IS DISTINCT FROM OLD.post THEN
IF EXISTS (SELECT 1 FROM publishing_attempts a
WHERE a.workspace_id = OLD.workspace_id AND a.draft_id = OLD.id
AND (a.status = 'unknown'
OR (a.status IN ('ready', 'in_progress') AND a.dispatch_started_at IS NOT NULL))) THEN
RAISE EXCEPTION 'draft content is locked while a dispatch is unsettled' USING ERRCODE = '55000';
END IF;
NEW.revision := OLD.revision + 1;
NEW.approved_revision := NULL;
NEW.approved_hash := NULL;
NEW.approved_at := NULL;
NEW.reviewer_user_id := NULL;
ELSE
NEW.revision := OLD.revision;
END IF;
RETURN NEW;
END $$;
-- wp3:phase dispatch_backfill batch
WITH batch AS (
SELECT id FROM publishing_attempts WHERE first_dispatch_started_at IS NULL AND dispatch_count > 0 LIMIT 1000 FOR UPDATE
)
UPDATE publishing_attempts a SET first_dispatch_started_at = CASE WHEN a.dispatch_count > 1 OR a.dispatch_started_at IS NULL THEN a.created_at ELSE a.dispatch_started_at END FROM batch WHERE a.id = batch.id;
-- wp3:phase receipt_recovery_index concurrent
CREATE INDEX CONCURRENTLY publishing_receipts_recovery ON publishing_receipts (workspace_id, attempt_id, received_at);
-- wp3:phase cutover transaction
SET LOCAL lock_timeout = '5s';
DROP FUNCTION publishing_due_schedules(integer);
CREATE FUNCTION publishing_due_schedules(p_limit integer DEFAULT 100)
RETURNS TABLE(workspace_id uuid, schedule_id uuid, idempotency_key text, generation bigint, publish_at timestamptz, evidence_only boolean)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT due.workspace_id, due.schedule_id, due.idempotency_key, due.generation, due.publish_at, due.evidence_only
FROM (
SELECT sp.workspace_id, sp.id AS schedule_id, sp.idempotency_key, sp.generation,
sp.publish_at, sp.next_attempt_at AS due_at, false AS evidence_only
FROM scheduled_publications sp
WHERE sp.state = 'pending' AND sp.execution_version = 1 AND sp.next_attempt_at <= now() AND NOT EXISTS (SELECT 1 FROM publishing_attempts a JOIN publishing_receipts r ON r.attempt_id = a.id AND r.workspace_id = a.workspace_id WHERE a.idempotency_key = sp.idempotency_key AND a.workspace_id = sp.workspace_id)
UNION ALL
SELECT a.workspace_id, a.schedule_id, a.idempotency_key, a.schedule_generation,
sp.publish_at, a.lease_until AS due_at, false AS evidence_only
FROM publishing_attempts a
JOIN scheduled_publications sp ON sp.id = a.schedule_id AND sp.workspace_id = a.workspace_id
WHERE a.status = 'in_progress' AND a.lease_until < now() AND NOT EXISTS (SELECT 1 FROM publishing_receipts r WHERE r.attempt_id = a.id AND r.workspace_id = a.workspace_id)
UNION ALL
SELECT a.workspace_id, a.schedule_id, a.idempotency_key, a.schedule_generation,
sp.publish_at, r.received_at AS due_at, true AS evidence_only
FROM publishing_attempts a
JOIN publishing_receipts r ON r.attempt_id = a.id AND r.workspace_id = a.workspace_id
JOIN scheduled_publications sp ON sp.id = a.schedule_id AND sp.workspace_id = a.workspace_id
WHERE (a.status IN ('ready', 'failed', 'unknown') OR (a.status = 'in_progress' AND a.lease_until < now()))
) due
ORDER BY due.due_at, due.schedule_id
LIMIT LEAST(GREATEST(COALESCE(p_limit, 100), 1), 100)
$$;
REVOKE ALL ON FUNCTION publishing_due_schedules(integer) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION publishing_due_schedules(integer) TO sp_app;

View file

@ -0,0 +1,93 @@
-- 020: WP3 analytics snapshots — monthly per-workspace rollups.
--
-- One row per (workspace, month), refreshed in place by the snapshot writer
-- (apps/api/src/analytics/snapshot.service.ts) with set-based SQL and an
-- idempotent upsert on (workspace_id, month). Months are materialized as
-- range partitions of a `month date` key, mirroring 014_live_chat.sql.
--
-- Creating future partitions: RLS on the partitioned parent does NOT apply to
-- direct leaf access, so every leaf carries its own ENABLE/FORCE RLS and
-- tenant_isolation policy. `analytics_ensure_month(date)` creates a month's
-- partition idempotently with those controls; this migration precreates the
-- previous, current and next month. Administrators run
-- SELECT analytics_ensure_month(date '2025-09-01');
-- ahead of each new month (cron it monthly, one statement, no downtime). The
-- DEFAULT partition below catches any month whose partition was not created,
-- so a missed call never drops data — it only loses pruning until the
-- partition is created and the rows moved into it.
--
-- Partitioned-leaf register entry (proposal §4.4, exception (a)): PK carries
-- the partition key, the table is never the target of an FK, and rows are
-- referenced only by (workspace_id, month) / id soft links.
--
-- Query catalog (owning WP: WP3 — packages/db does not exist in this repo, so
-- the catalog entries live here and in snapshot.service.ts):
-- analytics.snapshot.refresh INSERT .. ON CONFLICT (workspace_id, month) —
-- served by the PK (workspace_id, month, id); write path, p95 <= 250 ms.
-- analytics.snapshot.list keyset page ORDER BY month DESC — same PK,
-- backward index scan, p95 <= 20 ms.
-- analytics.snapshot.summary two PK point lookups (current + previous
-- month), p95 <= 20 ms.
CREATE TABLE analytics_snapshots (
id uuid NOT NULL DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Always a month start; the CHECK keeps the value aligned with its partition.
month date NOT NULL CHECK (month = date_trunc('month', month)::date),
publications_total bigint NOT NULL DEFAULT 0 CHECK (publications_total >= 0),
-- {provider: count} for publications published in the month.
publications_by_provider jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(publications_by_provider) = 'object'),
posts_archived_total bigint NOT NULL DEFAULT 0 CHECK (posts_archived_total >= 0),
-- Point-in-time current state at snapshot time (not a monthly delta):
-- connections not disconnected when the snapshot was taken.
active_connections int NOT NULL DEFAULT 0 CHECK (active_connections >= 0),
drafts_created_total bigint NOT NULL DEFAULT 0 CHECK (drafts_created_total >= 0),
publishing_attempts_total bigint NOT NULL DEFAULT 0 CHECK (publishing_attempts_total >= 0),
-- Attempts whose terminal status is 'failed', counted by their creation month.
publish_failures_total bigint NOT NULL DEFAULT 0 CHECK (publish_failures_total >= 0),
snapshot_started_at timestamptz NOT NULL DEFAULT now(),
snapshot_completed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, month, id),
UNIQUE (workspace_id, month)
) PARTITION BY RANGE (month);
-- Idempotent: safe to re-run for the same month.
CREATE FUNCTION analytics_ensure_month(p_month date) RETURNS void
LANGUAGE plpgsql SET search_path = public AS $$
DECLARE v_start date := date_trunc('month', p_month)::date;
v_table text := 'analytics_snapshots_' || to_char(v_start, 'YYYYMM');
BEGIN
EXECUTE format('CREATE TABLE IF NOT EXISTS %I PARTITION OF analytics_snapshots FOR VALUES FROM (%L) TO (%L)',
v_table, v_start, (v_start + interval '1 month')::date);
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', v_table);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', v_table);
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE schemaname = 'public' AND tablename = v_table AND policyname = 'tenant_isolation') THEN
EXECUTE format('CREATE POLICY tenant_isolation ON %I USING (workspace_id = NULLIF(current_setting(''app.workspace_id'', true), '''')::uuid) WITH CHECK (workspace_id = NULLIF(current_setting(''app.workspace_id'', true), '''')::uuid)', v_table);
END IF;
END $$;
REVOKE ALL ON FUNCTION analytics_ensure_month(date) FROM PUBLIC;
SELECT analytics_ensure_month((current_date - interval '1 month')::date);
SELECT analytics_ensure_month(current_date);
SELECT analytics_ensure_month((current_date + interval '1 month')::date);
-- Safety net for months nobody pre-created (see header).
CREATE TABLE analytics_snapshots_default PARTITION OF analytics_snapshots DEFAULT;
ALTER TABLE analytics_snapshots_default ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics_snapshots_default FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON analytics_snapshots_default
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE TRIGGER trg_analytics_snapshot_ws_immutable BEFORE UPDATE ON analytics_snapshots
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE analytics_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics_snapshots FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON analytics_snapshots
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
-- Grants on the parent cover all current and future partitions for queries
-- routed through it (014 style); direct leaf access also inherits from the
-- owner's default privileges (003).
GRANT SELECT, INSERT, UPDATE, DELETE ON analytics_snapshots TO sp_app;

View file

@ -0,0 +1,52 @@
-- 021: editable multi-platform streams columns — column definitions (WP3 §1.2, P0a).
-- stream_columns only: the resolved-source / ingest / fan-out / budget tables
-- (column_sources, feed_items, feed_item_columns, provider_budget_ledger) land
-- in their own forward-only migrations so each slice stays independently
-- reviewable. Additive: no existing table is touched.
--
-- `spec` and `refresh_policy` are JSONB blobs validated app-side by
-- @tactical-media-broadcast-hub/core (stream-columns.ts); invalid specs are rejected before
-- insert and never stored. The CHECKs here are only coarse backstops — the
-- authoritative typed validation lives in the app layer (017-style split).
CREATE TABLE stream_columns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
type text NOT NULL CHECK (type IN ('platform_feed','keyword','account_list','saved_search')),
title text NOT NULL CHECK (char_length(title) <= 80), -- user-editable; app layer enforces too
position integer NOT NULL DEFAULT 0 CHECK (position >= 0), -- dense rank; reorder rewrites the tail
spec jsonb NOT NULL CHECK (jsonb_typeof(spec) = 'object'),
refresh_policy jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(refresh_policy) = 'object'),
created_by uuid NOT NULL REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz -- soft delete keeps feed_items FK-stable (§1.2)
);
CREATE TRIGGER trg_stream_columns_ws_immutable BEFORE UPDATE ON stream_columns
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
-- Same fail-closed tenant isolation as every tenant table (001/006/017 style):
-- no app.workspace_id => NULL => no rows. FORCE binds sp_app too.
ALTER TABLE stream_columns ENABLE ROW LEVEL SECURITY;
ALTER TABLE stream_columns FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON stream_columns
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON stream_columns TO sp_app;
-- Dense column order per workspace (UI chips, seeding, reorder). Partial on
-- deleted_at IS NULL: soft-deleted columns keep their row (FK-stable items)
-- without reserving their slot, so reorder/seeding only fights live rows.
-- Named per design §1.2; lazy seeding relies on it for idempotence (§1.3).
CREATE UNIQUE INDEX stream_columns_ws_order_uq
ON stream_columns (workspace_id, position)
WHERE deleted_at IS NULL;
-- Workspace listing (GET /feeds/columns), newest-position reads served by the
-- unique index above; this covers soft-deleted lookups and count queries in
-- the partial-index style of workspace_invite_links_expiry (017).
CREATE INDEX stream_columns_ws_idx
ON stream_columns (workspace_id)
WHERE deleted_at IS NULL;

View file

@ -0,0 +1,136 @@
-- 022: WP6 live-stream destinations + stream outputs (go-live slice).
--
-- Aggregate boundary (proposal §4.4, WP6/WP9): a `live_stream_destinations`
-- row owns the platform-side live target — provider, ingest endpoint URL and
-- the vaulted ingest credential — and never references media outputs. A
-- `stream_outputs` row is a media-output fan-out from a virtual channel and
-- references EITHER a destination (credential resolved through the
-- destination at spawn) OR its own generic credential handle — never both
-- (CHECK below, mirrored in packages/core/src/vc.ts and API validation).
-- Enablement lives only on StreamOutput; the destination carries no
-- enabled flag of its own, so "disable one output" never mutates the shared
-- platform target.
--
-- FK actions per the WP6 lifecycle rule: both cross-aggregate FKs are
-- ON DELETE NO ACTION ON UPDATE RESTRICT; deletion is app-driven — 409 while
-- a referencing output is enabled, otherwise (disabled output) the app
-- disables dependents, revokes the ingest credential, then deletes the row.
-- `credentials` gains the tenant-composite key this migration needs
-- (tenant-composite FKs everywhere, per proposal §4.4/§4.5).
--
-- Query catalog (owning WP: WP6 — packages/db does not exist in this repo, so
-- the catalog lives here and in apps/api/src/live/live-destination.repository.ts):
-- live.destinations.list keyset page ORDER BY label — served by
-- live_destinations_ws_label; read path, p95 <= 20 ms.
-- live.destinations.usage point lookup of referencing outputs by
-- destination (tenant) — served by stream_outputs_destination partial
-- index; read path, p95 <= 20 ms.
-- live.outputs.channel channel fan-out list — served by
-- stream_outputs_channel; read path, p95 <= 20 ms.
BEGIN;
-- 001 created credentials with only a surrogate PK; 018 added the
-- tenant-composite key the cross-aggregate FK below requires.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'credentials_id_workspace_key') THEN
ALTER TABLE credentials ADD CONSTRAINT credentials_id_workspace_key UNIQUE (id, workspace_id);
END IF;
END $$;
CREATE TABLE live_stream_destinations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
label text NOT NULL CHECK (btrim(label) <> '' AND length(label) <= 120),
-- Provider of the platform-side target (manifest id of a connector); the
-- app resolves behaviour through the connector registry, never by branching
-- on this value.
provider text NOT NULL CHECK (btrim(provider) <> '' AND length(provider) <= 64),
-- Ingest endpoint (rtmp/rtmps/srt). Validated app-side by the shared
-- media-control endpoint shape validator before insert; the CHECKs here are
-- coarse backstops only.
endpoint_url text NOT NULL
CHECK (endpoint_url ~ '^(rtmp|rtmps|srt)://' AND length(endpoint_url) <= 2048),
-- Ingest credential (stream key etc.) vaulted in `credentials`; NULL only
-- for providers with no key. Tenant-composite FK; deleting the credential
-- row before the destination is a FK error by design (lifecycle revokes in
-- order: destination first, then its credential).
credential_id uuid,
-- Derived, explicitly-stale platform state (runtime truth is Redis obs:*).
last_platform_state text NOT NULL DEFAULT 'unknown'
CHECK (last_platform_state IN ('unknown', 'created', 'waiting', 'live', 'ended')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, id),
FOREIGN KEY (credential_id, workspace_id) REFERENCES credentials (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
CREATE TRIGGER trg_live_dest_ws_immutable BEFORE UPDATE ON live_stream_destinations
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
-- Same fail-closed tenant isolation as every tenant table (001/021 style):
-- no app.workspace_id => NULL => no rows. FORCE binds sp_app too.
ALTER TABLE live_stream_destinations ENABLE ROW LEVEL SECURITY;
ALTER TABLE live_stream_destinations FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON live_stream_destinations
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
-- Per-workspace listing ordered by label (live.destinations.list).
CREATE INDEX live_destinations_ws_label ON live_stream_destinations (workspace_id, label);
-- Media-output fan-out of a virtual channel onto one live target.
CREATE TABLE stream_outputs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
virtual_channel_id uuid NOT NULL,
label text NOT NULL CHECK (btrim(label) <> '' AND length(label) <= 120),
-- Output profile reference; resolution is app-side.
profile text NOT NULL DEFAULT '720p30'
CHECK (profile IN ('720p30', '1080p30', '1080p60')),
mode text NOT NULL DEFAULT 'passthrough' CHECK (mode IN ('passthrough', 'normalize')),
-- Enablement lives ONLY here (WP9): a disabled output stops pushing but the
-- destination row and its credential survive untouched.
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Exactly one of (destination_id, credential_handle_id): destination-
-- referenced outputs resolve the ingest credential through the destination
-- at spawn; generic outputs carry their own opaque vault handle. The
-- NOT-VALID backstop below is validated app-side (021-style split).
destination_id uuid,
credential_handle_id text,
CONSTRAINT stream_outputs_target_exclusive CHECK (
(destination_id IS NULL) <> (credential_handle_id IS NULL)
),
CONSTRAINT stream_outputs_handle_shape CHECK (
credential_handle_id IS NULL OR credential_handle_id ~ '^vh_[A-Za-z0-9_-]{16,128}$'
),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, virtual_channel_id, id),
FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id)
ON DELETE CASCADE ON UPDATE RESTRICT,
FOREIGN KEY (destination_id, workspace_id) REFERENCES live_stream_destinations (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
CREATE TRIGGER trg_stream_outputs_ws_immutable BEFORE UPDATE ON stream_outputs
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE stream_outputs ENABLE ROW LEVEL SECURITY;
ALTER TABLE stream_outputs FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON stream_outputs
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
-- References by destination (live.destinations.usage, 409 on delete).
CREATE INDEX stream_outputs_destination ON stream_outputs (workspace_id, destination_id)
WHERE destination_id IS NOT NULL;
-- Channel fan-out listing (live.outputs.channel).
CREATE INDEX stream_outputs_channel ON stream_outputs (workspace_id, virtual_channel_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON live_stream_destinations TO sp_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON stream_outputs TO sp_app;
COMMIT;

View file

@ -0,0 +1,160 @@
-- 023: WP7 bulk source import + live-settings guard.
--
-- Three additive pieces, one migration:
--
-- (1) `virtual_channel_sources` (013) is widened to admit *imported* sources.
-- 013's CHECK only modelled hand-entered HLS URLs and local playlists; a
-- PeerTube channel/live imported in bulk carries `remote_ref` +
-- `connection_id` and has an HLS URL only once a live is resolvable. The
-- dedupe key UNIQUE (workspace_id, virtual_channel_id, remote_ref) from
-- 013 is unchanged and stays the only dedupe authority: imports write
-- INSERT ... ON CONFLICT DO NOTHING RETURNING id.
--
-- (2) `virtual_channel_imports`: one row per bulk import request. The HTTP
-- request only parses + validates + creates the job row; resolution and
-- inserts run in the background, and the row is the progress/report
-- document (processed/total + imported/duplicate/invalid/failed counts
-- and a bounded per-entry outcome list). A `running` row whose heartbeat
-- (`updated_at`) went stale is reaped to `abandoned` on read, so a
-- process crash never leaves a job spinning forever.
--
-- (3) `virtual_channel_live_settings`: the channel's live output settings
-- (profile/resolution, latency, replay policy) behind an optimistic
-- concurrency `version`. Writers take the channel row FOR UPDATE (one
-- writer per channel), then CAS on `version`; a stale version or a
-- started live is a 409 with nothing written. The CHECKs mirror the
-- app-side validator (WP7 acceptance: permanentLive + saveReplay and an
-- out-of-range latencyMode are rejected by the DB on a direct insert
-- too), and a trigger keeps `version` monotone.
--
-- House style throughout: tenant-composite keys (id, workspace_id), composite
-- FKs that pin every child to one workspace's channel, reject_workspace_change
-- / reject_owner_change immutability, FORCE RLS with the NULLIF tenant policy,
-- grants to sp_app (003 adds the default-privilege side for future tables).
SET LOCAL lock_timeout = '5s';
-- (1) Imported sources: kind 'hls' now requires an HLS URL *or* a remote_ref
-- (a channel imported now, playback attached when its live starts). The
-- playlist arm is untouched: local, connectionless, remote_ref-less.
ALTER TABLE virtual_channel_sources DROP CONSTRAINT virtual_channel_sources_check;
ALTER TABLE virtual_channel_sources ADD CONSTRAINT virtual_channel_sources_check CHECK (
(
kind = 'hls' AND playlist_loop IS NULL
AND (hls_url IS NOT NULL OR remote_ref IS NOT NULL)
) OR (
kind = 'playlist' AND hls_url IS NULL AND remote_ref IS NULL
AND connection_id IS NULL AND playlist_loop IS NOT NULL
)
);
-- (2) Import jobs.
CREATE TABLE virtual_channel_imports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
virtual_channel_id uuid NOT NULL,
-- Connection the import resolves through (default host for bare handles).
connection_id uuid,
status text NOT NULL DEFAULT 'running'
CHECK (status IN ('running', 'completed', 'failed', 'abandoned')),
-- total counts every input row (valid or not); processed advances per row.
total int NOT NULL DEFAULT 0 CHECK (total >= 0 AND total <= 5000),
processed int NOT NULL DEFAULT 0 CHECK (processed >= 0 AND processed <= total),
imported int NOT NULL DEFAULT 0 CHECK (imported >= 0),
duplicate int NOT NULL DEFAULT 0 CHECK (duplicate >= 0),
invalid int NOT NULL DEFAULT 0 CHECK (invalid >= 0),
failed int NOT NULL DEFAULT 0 CHECK (failed >= 0),
-- Bounded per-entry report (first IMPORT_REPORT_ENTRY_LIMIT rows): what the
-- user pasted, what happened, why. Counts are always authoritative.
entries jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(entries) = 'array'),
error text,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
UNIQUE (id, workspace_id),
FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id)
ON DELETE CASCADE ON UPDATE RESTRICT,
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT DEFERRABLE INITIALLY DEFERRED
);
CREATE TRIGGER trg_vci_ws_immutable BEFORE UPDATE ON virtual_channel_imports
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_vci_owner_immutable BEFORE UPDATE ON virtual_channel_imports
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id');
ALTER TABLE virtual_channel_imports ENABLE ROW LEVEL SECURITY;
ALTER TABLE virtual_channel_imports FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON virtual_channel_imports
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE INDEX virtual_channel_imports_channel
ON virtual_channel_imports (workspace_id, virtual_channel_id, created_at DESC);
-- (3) Channel live settings, version-fenced.
CREATE TABLE virtual_channel_live_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
virtual_channel_id uuid NOT NULL,
-- Output shape: a named preset, or an explicit triple (custom).
output_profile text NOT NULL DEFAULT '720p30'
CHECK (output_profile IN ('720p30', '1080p30', '1080p60', 'custom')),
width int CHECK (width IS NULL OR (width >= 16 AND width <= 7680 AND width % 2 = 0)),
height int CHECK (height IS NULL OR (height >= 16 AND height <= 4320 AND height % 2 = 0)),
fps int CHECK (fps IS NULL OR (fps >= 1 AND fps <= 120)),
-- LiveVideoLatencyMode: 1=Default, 2=High latency, 3=Small latency.
latency_mode int NOT NULL DEFAULT 1 CHECK (latency_mode IN (1, 2, 3)),
permanent_live boolean NOT NULL DEFAULT false,
save_replay boolean NOT NULL DEFAULT false,
-- Replay privacy etc.; only meaningful while save_replay is on.
replay_settings jsonb CHECK (replay_settings IS NULL OR jsonb_typeof(replay_settings) = 'object'),
-- Latch: once the channel's live has started, settings are frozen (409).
live_started_at timestamptz,
-- Optimistic concurrency: every accepted write bumps version exactly once.
version bigint NOT NULL DEFAULT 0 CHECK (version >= 0),
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, virtual_channel_id),
UNIQUE (id, workspace_id),
FOREIGN KEY (workspace_id, virtual_channel_id) REFERENCES virtual_channels (workspace_id, id)
ON DELETE CASCADE ON UPDATE RESTRICT,
CHECK (
(output_profile <> 'custom' AND width IS NULL AND height IS NULL AND fps IS NULL)
OR (output_profile = 'custom' AND width IS NOT NULL AND height IS NOT NULL AND fps IS NOT NULL)
),
-- PeerTube rejects saveReplay + permanentLive (400); we refuse it earlier
-- and the DB refuses it for direct inserts.
CHECK (NOT (permanent_live AND save_replay)),
CHECK (save_replay OR replay_settings IS NULL)
);
CREATE TRIGGER trg_vcls_ws_immutable BEFORE UPDATE ON virtual_channel_live_settings
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_vcls_owner_immutable BEFORE UPDATE ON virtual_channel_live_settings
FOR EACH ROW EXECUTE FUNCTION reject_owner_change('virtual_channel_id');
-- version never regresses; a stale CAS writer cannot rewind the fence.
CREATE FUNCTION reject_live_settings_version_regression() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.version < OLD.version THEN
RAISE EXCEPTION 'live settings version cannot decrease' USING ERRCODE = '23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER trg_vcls_version_monotone BEFORE UPDATE ON virtual_channel_live_settings
FOR EACH ROW EXECUTE FUNCTION reject_live_settings_version_regression();
ALTER TABLE virtual_channel_live_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE virtual_channel_live_settings FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON virtual_channel_live_settings
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON virtual_channel_imports, virtual_channel_live_settings TO sp_app;

View file

@ -0,0 +1,477 @@
-- 024: chat governance spine — chatgov design §2, P0 slice (glm).
--
-- Tables: polls, poll_votes, posting_rules, posting_policy_snapshots,
-- topic_bindings, chat_command_audit, chat_identities, gate_decisions.
-- poll_mirrors (card edit bookkeeping) is deliberately NOT here: it belongs to
-- the P5 mirror slice and lands in its own forward migration.
--
-- Design notes (chatgov-design.md §0/§1/§2):
-- * Polls are OURS, not Telegram's (D1): ballots live in poll_votes, the
-- Telegram card is only a mirror. `polls.rule` is the compiled §1a object
-- snapshotted at open and never re-resolved mid-flight; `policy_version`
-- is its deterministic hash pinned at open and at every decision.
-- * One open gate per draft (partial unique index) stops poll spam gating
-- the same draft twice; a revote is a NEW poll with parent_poll_id.
-- * posting_rules are data with precedence topic > content_type > account >
-- workspace (D4). Only ONE live rule per (scope, account, topic,
-- content_type) slot: the partial NULLS NOT DISTINCT index ignores
-- superseded rows so a revision frees its slot without losing lineage.
-- Effective policies are compiled at SAVE time into
-- posting_policy_snapshots (whole-block replacement, no field merges).
-- * topic_bindings are OUR registry of (connection, chat, thread) — the Bot
-- API cannot list topics, so what we created here is authoritative (D3).
-- `message_thread_id IS NULL` means the channel root / whole group, so the
-- slot key needs NULLS NOT DISTINCT.
-- * chat_identities key on the immutable numeric provider user id — never a
-- username (§6). /register creates `pending`; console approval flips to
-- `approved`; nothing self-elevates.
-- * chat_command_audit records EVERY command attempt including rejects
-- (rate_limited, unauthorized, unknown_command). UNIQUE (workspace,
-- provider, update_id) is the replay killer for Telegram updates.
-- * gate_decisions: one row per destination per gate evaluation (E1/E2/E3,
-- D8 partial fan-out is a first-class outcome). `inputs` carries the
-- quorum state at close; reason codes are machine-readable (§4).
--
-- Retention classes (WP2 convention; no registry table exists yet, so this
-- comment is the registration): poll_votes, chat_command_audit and
-- gate_decisions are append-hot and are monthly-partition candidates once a
-- partition manager lands (014/020 style leaves). polls, posting_rules,
-- posting_policy_snapshots, topic_bindings and chat_identities stay
-- unpartitioned — they are FK parents / small current-state tables.
--
-- RLS / NULLIF / reject_workspace_change / sp_app grants follow 001/014/021
-- conventions: no app.workspace_id => NULL => no rows; FORCE binds sp_app too.
-- ---------------------------------------------------------------------------
-- topic_bindings
-- ---------------------------------------------------------------------------
CREATE TABLE topic_bindings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Telegram connection (one bot token per connection).
connection_id uuid NOT NULL,
-- Numeric -100… chat id preferred (usernames drift). Text, not bigint:
-- provider-neutral and stable across platforms.
chat_id text NOT NULL CHECK (chat_id ~ '^-?[0-9]{1,32}$'),
-- Telegram message_thread_id; NULL = channel root / whole group.
message_thread_id text CHECK (message_thread_id IS NULL OR message_thread_id ~ '^[0-9]{1,32}$'),
binding_type text NOT NULL CHECK (binding_type IN ('announce', 'control', 'audit')),
-- Telegram topic name limit is 128.
name text NOT NULL CHECK (char_length(btrim(name)) BETWEEN 1 AND 128),
-- One of the 7 fixed Telegram icon colors (7322096, 16766592, 13338331,
-- 9367192, 16749490, 16478047, 8914859).
icon_color integer CHECK (icon_color IS NULL OR icon_color IN
(7322096, 16766592, 13338331, 9367192, 16749490, 16478047, 8914859)),
state text NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'closed', 'archived')),
-- Accounts (connections) that post into this topic. Array members cannot be
-- FK-enforced in PostgreSQL; the app layer validates they belong to the same
-- workspace (chatgov policy-service).
account_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
-- Control bindings: the virtual channel the commands act on.
linked_channel_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, id),
UNIQUE NULLS NOT DISTINCT (workspace_id, connection_id, chat_id, message_thread_id),
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT,
FOREIGN KEY (linked_channel_id, workspace_id) REFERENCES virtual_channels (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
CREATE TRIGGER trg_topic_bindings_ws_immutable BEFORE UPDATE ON topic_bindings
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE topic_bindings ENABLE ROW LEVEL SECURITY;
ALTER TABLE topic_bindings FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON topic_bindings
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON topic_bindings TO sp_app;
-- One control binding per virtual channel (design §2): two control topics
-- commanding the same channel would race the director.
CREATE UNIQUE INDEX topic_bindings_one_control
ON topic_bindings (workspace_id, linked_channel_id)
WHERE binding_type = 'control' AND linked_channel_id IS NOT NULL;
-- Topic screen grid + gateway binding resolution (chat_id + thread -> row).
CREATE INDEX topic_bindings_ws_conn ON topic_bindings (workspace_id, connection_id, state);
-- ---------------------------------------------------------------------------
-- polls
-- ---------------------------------------------------------------------------
CREATE TABLE polls (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Live-chat channel the poll is scoped to (nullable: console-only poll).
channel_id uuid,
-- Vote scoped to a subchat when set (topic binding), else the whole chat.
topic_binding_id uuid,
subject_kind text NOT NULL CHECK (subject_kind IN ('draft', 'chat_post', 'none')),
-- Set when subject_kind = 'draft'. Drafts are the only gating subject (D5).
draft_id uuid,
question text NOT NULL CHECK (char_length(question) BETWEEN 1 AND 300),
-- Revote lineage: a re-vote is a NEW poll pointing at the closed one.
parent_poll_id uuid,
-- Compiled §1a pass-criteria object, snapshotted at open (never re-resolved).
rule jsonb NOT NULL CHECK (jsonb_typeof(rule) = 'object'),
-- Deterministic hash of `rule` at open (pinned on every decision too).
policy_version text NOT NULL CHECK (btrim(policy_version) <> ''),
-- named | anonymous, extracted from `rule` for queryability. Anonymous polls
-- store NO voter linkage: poll_votes rows are written for named mode only
-- (§6 privacy; anonymous sentiment rides native Telegram polls in P7).
anonymity text NOT NULL DEFAULT 'named' CHECK (anonymity IN ('named', 'anonymous')),
state text NOT NULL DEFAULT 'open'
CHECK (state IN ('open', 'passed', 'failed', 'vetoed', 'quorum_unreachable', 'cancelled')),
opens_at timestamptz NOT NULL DEFAULT now(),
-- opens_at + rule.timebox.duration.
closes_at timestamptz NOT NULL,
closed_at timestamptz,
close_reason text CHECK (close_reason IN ('quorum_early', 'timebox', 'veto', 'cancelled')),
-- Exactly-once close claim: the state flip and its downstream trigger share
-- one transaction guarded by this column (P2 poll-service claims it).
outbox_state text NOT NULL DEFAULT 'none' CHECK (outbox_state IN ('none', 'claimed', 'done')),
created_by uuid NOT NULL REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, id),
CHECK ((state = 'open') = (closed_at IS NULL)),
CHECK (close_reason IS NULL OR state <> 'open'),
FOREIGN KEY (channel_id, workspace_id) REFERENCES virtual_channels (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT,
FOREIGN KEY (topic_binding_id, workspace_id) REFERENCES topic_bindings (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
CREATE TRIGGER trg_polls_ws_immutable BEFORE UPDATE ON polls
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE polls ENABLE ROW LEVEL SECURITY;
ALTER TABLE polls FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON polls
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON polls TO sp_app;
-- One open gate per draft (design §2): a second open poll on the same draft is
-- refused by the index, not by application discipline.
CREATE UNIQUE INDEX polls_open_one_per_draft ON polls (workspace_id, draft_id)
WHERE state = 'open' AND draft_id IS NOT NULL;
-- Close sweep + overlay listing ("closing soon" chips).
CREATE INDEX polls_ws_state ON polls (workspace_id, state, closes_at);
-- Revote lineage walks (blocked queue "open re-vote" action).
CREATE INDEX polls_ws_parent ON polls (workspace_id, parent_poll_id) WHERE parent_poll_id IS NOT NULL;
-- Self-reference after the table exists (avoids a forward declaration).
ALTER TABLE polls ADD CONSTRAINT polls_parent_fk
FOREIGN KEY (parent_poll_id, workspace_id) REFERENCES polls (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT;
-- ---------------------------------------------------------------------------
-- poll_votes
-- ---------------------------------------------------------------------------
CREATE TABLE poll_votes (
poll_id uuid NOT NULL,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
voter_kind text NOT NULL CHECK (voter_kind IN ('user', 'chat_identity', 'system')),
-- Resolved workspace user, when known (named mode).
voter_user_id uuid REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
-- provider:from.id (numeric for Telegram). Idempotency key half.
voter_remote_id text NOT NULL CHECK (btrim(voter_remote_id) <> ''),
voter_handle text NOT NULL CHECK (btrim(voter_handle) <> ''),
ballot text NOT NULL CHECK (ballot IN ('yes', 'no', 'veto', 'abstain')),
-- Eligibility AT CAST (informational; eligibility is re-read at close).
eligible boolean NOT NULL DEFAULT true,
-- Ineligible votes are stored, never counted (D9 / GitLab pattern).
counted boolean NOT NULL DEFAULT true,
-- INELIGIBLE_ACTOR | SELF_APPROVAL_DENIED | ...
ineligible_reason text,
cast_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (poll_id, voter_remote_id),
FOREIGN KEY (poll_id, workspace_id) REFERENCES polls (id, workspace_id)
ON DELETE CASCADE ON UPDATE RESTRICT
);
CREATE TRIGGER trg_poll_votes_ws_immutable BEFORE UPDATE ON poll_votes
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE poll_votes ENABLE ROW LEVEL SECURITY;
ALTER TABLE poll_votes FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON poll_votes
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON poll_votes TO sp_app;
-- Tally reads (quorum evaluation on every ballot) hit counted votes only.
CREATE INDEX poll_votes_poll ON poll_votes (poll_id, counted);
-- ---------------------------------------------------------------------------
-- posting_rules
-- ---------------------------------------------------------------------------
CREATE TABLE posting_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Precedence (D4): topic > content_type > account > workspace.
scope text NOT NULL CHECK (scope IN ('workspace', 'account', 'content_type', 'topic')),
-- Connection id (the policy anchor is the ACCOUNT, platform is an attribute).
-- NULL for workspace-scope rules and workspace-wide content_type rules.
account_id uuid,
-- Set when scope = 'topic'.
topic_binding_id uuid,
-- 'alert' | 'video' | ... when scope = 'content_type'.
content_type text,
-- §1a-shaped rule object minus the subject; validated app-side by
-- apps/api/src/chatgov/policy.ts before insert (021-style split: the CHECKs
-- here are only coarse backstops).
policy jsonb NOT NULL CHECK (jsonb_typeof(policy) = 'object'),
-- Compiled snapshot hash at save (deterministic; identical content shares
-- a version, so a re-save of the same policy is a no-op version-wise).
policy_version text NOT NULL CHECK (btrim(policy_version) <> ''),
-- Shadow mode: evaluate + log, never block/fire (POLICY_DRY_RUN).
dry_run boolean NOT NULL DEFAULT false,
created_by uuid NOT NULL REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Revision chain hook (P6 rules editor): a superseded row no longer holds
-- its scope slot (see the partial unique index below) but keeps lineage.
superseded_by uuid,
UNIQUE (workspace_id, id),
FOREIGN KEY (account_id, workspace_id) REFERENCES connections (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT,
FOREIGN KEY (topic_binding_id, workspace_id) REFERENCES topic_bindings (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
CREATE TRIGGER trg_posting_rules_ws_immutable BEFORE UPDATE ON posting_rules
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE posting_rules ENABLE ROW LEVEL SECURITY;
ALTER TABLE posting_rules FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON posting_rules
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON posting_rules TO sp_app;
-- ONE live rule per scope slot. NULLS NOT DISTINCT because "no account" /
-- "no topic" / "no content type" is itself a slot value (workspace default,
-- channel root); a plain UNIQUE would allow unbounded duplicates there.
-- Partial on superseded_by IS NULL so a revision frees the slot.
CREATE UNIQUE INDEX posting_rules_live_uq
ON posting_rules (workspace_id, scope, account_id, topic_binding_id, content_type)
NULLS NOT DISTINCT
WHERE superseded_by IS NULL;
-- Rules-editor listing and per-destination resolution scans.
CREATE INDEX posting_rules_ws_scope ON posting_rules (workspace_id, scope, account_id, topic_binding_id)
WHERE superseded_by IS NULL;
ALTER TABLE posting_rules ADD CONSTRAINT posting_rules_superseded_fk
FOREIGN KEY (superseded_by, workspace_id) REFERENCES posting_rules (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT;
-- ---------------------------------------------------------------------------
-- chat_identities
-- ---------------------------------------------------------------------------
CREATE TABLE chat_identities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
provider text NOT NULL CHECK (btrim(provider) <> ''),
-- Immutable numeric provider user id (Telegram from.id) — NEVER a username
-- (§6: usernames are never trusted for authorization).
remote_user_id text NOT NULL CHECK (remote_user_id ~ '^[0-9]{1,20}$'),
-- Linked workspace user once /register is approved in the console.
user_id uuid REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
display_name text,
-- §1a admins-arm eligibility: membership role ∈ {Owner, Admin} OR this
-- override ≥ that role (evaluated at CLOSE time — TOCTOU guard). NULL = the
-- linked membership role governs. Written only by chatgov.manage holders.
role_override text CHECK (role_override IS NULL OR role_override IN ('Moderator', 'Editor', 'Publisher', 'Approver', 'Admin', 'Owner')),
-- /register creates pending; approval is a console action (chatgov.manage),
-- never self-service.
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'banned')),
approved_by uuid REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
first_seen_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, provider, remote_user_id),
-- Approval must name its approver.
CHECK (status <> 'approved' OR approved_by IS NOT NULL)
);
CREATE TRIGGER trg_chat_identities_ws_immutable BEFORE UPDATE ON chat_identities
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE chat_identities ENABLE ROW LEVEL SECURITY;
ALTER TABLE chat_identities FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON chat_identities
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON chat_identities TO sp_app;
-- Gateway authorization path: numeric from.id -> identity -> membership role.
CREATE UNIQUE INDEX chat_identities_ws_user
ON chat_identities (workspace_id, provider, user_id) WHERE user_id IS NOT NULL AND status = 'approved';
-- ---------------------------------------------------------------------------
-- chat_command_audit
-- ---------------------------------------------------------------------------
CREATE TABLE chat_command_audit (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Telegram update id (or provider event id): the replay killer.
update_id bigint NOT NULL CHECK (update_id > 0),
-- Topic binding the command arrived on (NULL: unbound chat — itself a
-- reject reason, NO_CHANNEL_BOUND).
binding_id uuid,
provider text NOT NULL CHECK (btrim(provider) <> ''),
remote_user_id text NOT NULL CHECK (btrim(remote_user_id) <> ''),
-- Resolved identity's workspace user, if any.
user_id uuid REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
command text NOT NULL CHECK (btrim(command) <> ''),
args jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(args) = 'object'),
-- RBAC permission the command required (NULL for /help, /register).
permission text,
-- Every attempt is audited, including rejects (§6).
decision text NOT NULL CHECK (decision IN ('accepted', 'rejected', 'unauthorized', 'unknown_command', 'rate_limited')),
-- Machine code: INELIGIBLE_ACTOR | LOCKED_CHANNEL | CONFIRM_EXPIRED | NO_CHANNEL_BOUND | ...
reason text,
-- Confirmation nonce when confirm-gated (P4).
nonce text,
-- Chat nonce reused as the director Idempotency-Key (P3/P4).
idempotency_key text,
-- Receipt fingerprint / poll id / draft id.
result_ref jsonb CHECK (result_ref IS NULL OR jsonb_typeof(result_ref) = 'object'),
received_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, provider, update_id),
FOREIGN KEY (binding_id, workspace_id) REFERENCES topic_bindings (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
CREATE TRIGGER trg_chat_command_audit_ws_immutable BEFORE UPDATE ON chat_command_audit
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE chat_command_audit ENABLE ROW LEVEL SECURITY;
ALTER TABLE chat_command_audit FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON chat_command_audit
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON chat_command_audit TO sp_app;
CREATE INDEX chat_command_audit_ws_time ON chat_command_audit (workspace_id, received_at DESC);
-- ---------------------------------------------------------------------------
-- posting_policy_snapshots — effective policy resolved at SAVE time (D4).
-- Evaluation reads ONLY snapshots; rules are re-compiled on every rule change.
-- ---------------------------------------------------------------------------
CREATE TABLE posting_policy_snapshots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
destination_kind text NOT NULL CHECK (destination_kind IN ('account', 'topic')),
-- The account (connection) the destination posts through; topic snapshots
-- carry their owning connection here too.
account_id uuid NOT NULL,
-- NULL = the account root (whole account, no topic override).
topic_binding_id uuid,
-- '' = the destination's default (no content-type override); content_type
-- rules compile one snapshot per type so E3 can re-check by (account,
-- [topic], contentType) without re-resolving precedence at dispatch time.
content_type text NOT NULL DEFAULT '' CHECK (content_type ~ '^[a-z0-9_.-]{0,40}$'),
-- Compiled §1a object. Immutable per policy_version: a rule change writes a
-- NEW version; decisions pin the version they evaluated.
effective jsonb NOT NULL CHECK (jsonb_typeof(effective) = 'object'),
policy_version text NOT NULL CHECK (btrim(policy_version) <> ''),
-- Audit: which scopes/rules contributed to this resolution.
resolved_from jsonb NOT NULL CHECK (jsonb_typeof(resolved_from) = 'object'),
updated_at timestamptz NOT NULL DEFAULT now(),
-- NULLS NOT DISTINCT: topic_binding_id NULL is its own slot (account root).
-- The design sketch's PK omitted content_type; the column is kept in the
-- slot key so a content_type rule cannot silently overwrite the account's
-- default snapshot (whole-block replacement, one row per resolved slot).
UNIQUE NULLS NOT DISTINCT (workspace_id, destination_kind, account_id, topic_binding_id, content_type),
FOREIGN KEY (account_id, workspace_id) REFERENCES connections (id, workspace_id)
ON DELETE CASCADE ON UPDATE RESTRICT,
FOREIGN KEY (topic_binding_id, workspace_id) REFERENCES topic_bindings (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
CREATE TRIGGER trg_posting_policy_snapshots_ws_immutable BEFORE UPDATE ON posting_policy_snapshots
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE posting_policy_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE posting_policy_snapshots FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON posting_policy_snapshots
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON posting_policy_snapshots TO sp_app;
-- E3 pre-dispatch re-check reads by (workspace, account[, topic][, contentType]).
CREATE INDEX posting_policy_snapshots_ws_acct
ON posting_policy_snapshots (workspace_id, account_id, updated_at DESC);
-- ---------------------------------------------------------------------------
-- gate_decisions — one row per destination per gate evaluation (D8).
-- ---------------------------------------------------------------------------
CREATE TABLE gate_decisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
draft_id uuid NOT NULL,
-- {platform, accountId, topicBindingId?} — the destination this row is about.
destination jsonb NOT NULL CHECK (jsonb_typeof(destination) = 'object'),
gate text NOT NULL CHECK (gate IN ('E1', 'E2', 'E3')),
-- Which posting rule fired (NULL when the fail-closed default applied).
rule_id uuid,
policy_version text NOT NULL CHECK (btrim(policy_version) <> ''),
outcome text NOT NULL CHECK (outcome IN ('fired', 'blocked', 'pending')),
-- Approvals, poll tally, quorum state at close (arm-by-arm got/needed).
inputs jsonb NOT NULL CHECK (jsonb_typeof(inputs) = 'object'),
-- INSUFFICIENT_QUORUM | INELIGIBLE_ACTOR | SELF_APPROVAL_DENIED | VETOED |
-- TIMEBOX_EXPIRED_AUTO_REJECT | RESET_ON_CHANGE | PENDING_POLL |
-- OUTSIDE_POLICY | POLICY_DRY_RUN | OVERRIDDEN_BY_OWNER | ...
reason_codes text[] NOT NULL DEFAULT '{}'::text[],
-- 'system:gate-e3' | 'user:<id>' | 'chat_poll:<pollId>'
actor text NOT NULL CHECK (btrim(actor) <> ''),
-- E2 rows carry the poll whose outcome this decision persists.
poll_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, id),
FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts (id, workspace_id)
ON DELETE CASCADE ON UPDATE RESTRICT,
FOREIGN KEY (rule_id, workspace_id) REFERENCES posting_rules (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT,
FOREIGN KEY (poll_id, workspace_id) REFERENCES polls (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
CREATE TRIGGER trg_gate_decisions_ws_immutable BEFORE UPDATE ON gate_decisions
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE gate_decisions ENABLE ROW LEVEL SECURITY;
ALTER TABLE gate_decisions FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON gate_decisions
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON gate_decisions TO sp_app;
-- Blocked queue + draft timeline (P1) read by (workspace, draft, gate).
CREATE INDEX gate_decisions_draft ON gate_decisions (workspace_id, draft_id, gate);
-- Poll outcome lookup from the blocked queue's "open re-vote" action.
CREATE INDEX gate_decisions_poll ON gate_decisions (workspace_id, poll_id) WHERE poll_id IS NOT NULL;
-- ---------------------------------------------------------------------------
-- RBAC: chatgov permissions, seeded in the 002 pattern (ON CONFLICT DO NOTHING).
-- chatgov.manage = rules editor, topic registry, identity approval, policy
-- compile — Owner/Admin. chatgov.vote = open/cast governance votes — the
-- content + broadcast roles. One statement => one authz-epoch bump (018).
-- ---------------------------------------------------------------------------
INSERT INTO role_permissions (role, permission)
SELECT v.role, v.permission
FROM (VALUES
('Owner', 'chatgov.manage'),
('Owner', 'chatgov.vote'),
('Admin', 'chatgov.manage'),
('Admin', 'chatgov.vote'),
('Publisher', 'chatgov.vote'),
('Editor', 'chatgov.vote'),
('Approver', 'chatgov.vote'),
('Moderator', 'chatgov.vote'),
('BroadcastOperator', 'chatgov.vote')
) AS v(role, permission)
ON CONFLICT DO NOTHING;

View file

@ -0,0 +1,55 @@
-- 025: WP15 archive full-text search (firehose research recommendation:
-- Postgres FTS phase-1) over archived_feed_items.
--
-- Search surface:
-- * tsvector over text_content + channel/attribution metadata, kept in sync
-- by trigger (the normalizer writes text_content on insert AND on edits,
-- so a plain trigger beats a generated column: 015's reject_workspace_change
-- trigger guards workspace_id, and edits must reindex text).
-- * GIN indexes: tsvector lookup + pg_trgm similarity for typo tolerance.
-- * The API ORs websearch_to_tsquery matching with pg_trgm similarity so a
-- misspelled term still matches via trigram distance (similarity >= 0.3).
-- * RLS is untouched: archived_feed_items keeps its tenant_isolation policy
-- from 015; search runs inside withTenant() so FORCE RLS binds honestly.
--
-- CREATE EXTENSION requires privileges sp_app lacks; this migration runs as
-- the table owner only (015 grants are preserved below for completeness).
CREATE EXTENSION IF NOT EXISTS pg_trgm;
ALTER TABLE archived_feed_items ADD COLUMN search_vector tsvector NOT NULL DEFAULT '';
-- String-valued attribution keys worth indexing as metadata (normalizer maps
-- Telegram author/origin fields here). Kept narrow on purpose.
CREATE FUNCTION archived_attribution_text(attribution jsonb)
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT coalesce(string_agg(value, ' '), '')
FROM jsonb_each_text(attribution)
WHERE key IN ('channelUsername', 'channelTitle', 'author', 'authorSignature',
'forwardFromChat', 'forwardFromUser', 'signature')
$$;
CREATE FUNCTION archived_feed_items_search_vector() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.channel_id, '')), 'A')
|| setweight(to_tsvector('simple', archived_attribution_text(NEW.attribution)), 'B')
|| to_tsvector('simple', coalesce(NEW.text_content, ''));
RETURN NEW;
END $$;
CREATE TRIGGER trg_archived_feed_items_search
BEFORE INSERT OR UPDATE OF text_content, channel_id, attribution
ON archived_feed_items FOR EACH ROW
EXECUTE FUNCTION archived_feed_items_search_vector();
-- Exact/lexical lookups. 'simple' config on purpose: the corpus is a
-- multilingual firehose, no stemming dictionary is "right", so phase-1 is
-- exact term matching plus trigram fuzziness.
CREATE INDEX archived_feed_items_search ON archived_feed_items USING gin (search_vector);
-- Fuzzy path (typo tolerance) + unindexed-corpus ILIKE support on text.
CREATE INDEX archived_feed_items_text_trgm ON archived_feed_items USING gin (text_content gin_trgm_ops);
CREATE INDEX archived_feed_items_channel_trgm ON archived_feed_items USING gin (channel_id gin_trgm_ops);
GRANT SELECT ON archived_feed_items TO sp_app;

View file

@ -0,0 +1,72 @@
-- P2 competitive short-post variants. 022/023/024 exist. The runner keys
-- migrations by filename; 025_archive_search.sql belongs to another slice.
ALTER TABLE polls
ADD COLUMN kind text NOT NULL DEFAULT 'yes_no' CHECK (kind IN ('yes_no', 'variant_selection')),
ADD COLUMN winner_option_id uuid,
ADD COLUMN draft_revision bigint,
ADD COLUMN content_kind text NOT NULL DEFAULT 'short_post' CHECK (content_kind IN ('short_post', 'document')),
ADD COLUMN eligible_user_ids uuid[];
-- document is a schema seam only; P2 accepts short_post exclusively.
ALTER TABLE polls DROP CONSTRAINT polls_state_check;
ALTER TABLE polls ADD CONSTRAINT polls_state_check CHECK
(state IN ('open', 'passed', 'failed', 'vetoed', 'quorum_unreachable', 'cancelled', 'stale'));
ALTER TABLE polls DROP CONSTRAINT polls_close_reason_check;
ALTER TABLE polls ADD CONSTRAINT polls_close_reason_check CHECK
(close_reason IN ('quorum_early', 'timebox', 'veto', 'cancelled', 'quorum_unreachable', 'reset_on_change'));
CREATE TABLE poll_options (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
poll_id uuid NOT NULL,
seq integer NOT NULL CHECK (seq > 0),
-- Future document revision chains can anchor here. Short posts use the
-- poll's numeric draft_revision plus this immutable text snapshot.
draft_revision_id uuid,
text text NOT NULL CHECK (char_length(text) BETWEEN 1 AND 5000),
submitted_by uuid NOT NULL REFERENCES users(id) ON DELETE NO ACTION ON UPDATE RESTRICT,
submitted_at timestamptz NOT NULL DEFAULT clock_timestamp(),
UNIQUE (poll_id, seq),
UNIQUE (id, poll_id, workspace_id),
FOREIGN KEY (poll_id, workspace_id) REFERENCES polls(id, workspace_id) ON DELETE CASCADE ON UPDATE RESTRICT
);
CREATE TRIGGER trg_poll_options_ws_immutable BEFORE UPDATE ON poll_options
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
-- Artifacts cannot be edited or individually removed, even by SQL callers.
-- Cascading workspace/poll retention remains possible.
CREATE FUNCTION chatgov_option_immutable() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' OR pg_trigger_depth() = 1 THEN
RAISE EXCEPTION 'poll options are immutable' USING ERRCODE = '55000';
END IF;
RETURN OLD;
END $$;
CREATE TRIGGER trg_poll_options_immutable BEFORE UPDATE OR DELETE ON poll_options
FOR EACH ROW EXECUTE FUNCTION chatgov_option_immutable();
ALTER TABLE poll_options ENABLE ROW LEVEL SECURITY;
ALTER TABLE poll_options FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON poll_options
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON poll_options TO sp_app;
ALTER TABLE poll_votes ADD COLUMN option_id uuid;
ALTER TABLE poll_votes ADD CONSTRAINT poll_votes_option_fk
FOREIGN KEY (option_id, poll_id, workspace_id) REFERENCES poll_options(id, poll_id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT;
ALTER TABLE poll_votes ADD CONSTRAINT poll_votes_option_ballot_check CHECK (option_id IS NULL OR ballot = 'yes');
ALTER TABLE polls ADD CONSTRAINT polls_winner_option_fk
FOREIGN KEY (winner_option_id, id, workspace_id) REFERENCES poll_options(id, poll_id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT;
ALTER TABLE polls ADD CONSTRAINT polls_winner_state_check CHECK
(winner_option_id IS NULL OR (kind = 'variant_selection' AND state = 'passed'));
ALTER TABLE poll_votes ADD COLUMN voter_identity_id uuid REFERENCES chat_identities(id) ON DELETE NO ACTION ON UPDATE RESTRICT;
ALTER TABLE polls ADD COLUMN final_evaluation jsonb;
-- Narrow expiry discovery follows the publishing due-schedule house pattern:
-- returns references only, never ballots/content. Mutations still use RLS.
CREATE FUNCTION chatgov_due_polls() RETURNS TABLE(workspace_id uuid, id uuid)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
SELECT p.workspace_id, p.id FROM polls p WHERE p.state = 'open' AND p.closes_at <= now()
ORDER BY p.closes_at LIMIT 500
$$;
REVOKE ALL ON FUNCTION chatgov_due_polls() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION chatgov_due_polls() TO sp_app;

View file

@ -0,0 +1,230 @@
-- 026: columns P1 — persistence + RBAC (WP3 §1.2/§3, §6 P1 row).
--
-- (025 was taken by WP15 archive search; this file is the columns slice.)
--
-- Three things land here, in 024's many-tables-one-file style:
--
-- 1. The resolved-source / ingest / fan-out tables 021 deliberately left
-- for their own slice: column_sources, feed_items, feed_item_columns.
-- (provider_budget_ledger is P2b/P5 territory — no ledger caller exists
-- yet, so the table is not created here.)
-- 2. host_budgets — the shared per-host egress budget table owned by
-- @tactical-media-broadcast-hub/egress (its migrations/001_host_budgets.sql, applied
-- verbatim with credit; the egress package never migrates at runtime and
-- expects the application to apply and ledger it). The columns scheduler
-- is the first app-side caller of HostBudgets that does not go through
-- the whole egressFetch client, so the table must exist before P1's
-- poll loop draws from it. IF NOT EXISTS keeps this idempotent if a
-- later egress-integration migration also carries it.
-- 3. The `streams.columns.manage` RBAC seed (design §1.4/§0.5): write
-- permission on columns, granted to Owner/Admin/Editor by default.
-- Reads stay on `draft.view`.
--
-- RLS / NULLIF / reject_workspace_change / sp_app grants follow the
-- 001/014/021/024 conventions: no app.workspace_id => NULL => no rows; FORCE
-- binds sp_app too. `spec`/`refresh_policy` stay validated app-side by
-- @tactical-media-broadcast-hub/core (021 note); the CHECKs here are coarse backstops.
-- ---------------------------------------------------------------------------
-- column_sources — what the scheduler actually polls for a column (§1.2)
-- ---------------------------------------------------------------------------
CREATE TABLE column_sources (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
column_id uuid NOT NULL REFERENCES stream_columns(id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Identity of the thing polled (manifest id of a connector).
provider text NOT NULL CHECK (btrim(provider) <> '' AND length(provider) <= 64),
-- NULL for keyless sources (YouTube RSS, public Bluesky/Mastodon) — those
-- draw from the per-host budget only (§1.2 "Keyless sources").
connection_id uuid,
source_kind text NOT NULL CHECK (source_kind IN ('home','mentions','search','author')),
-- Platform-adapted query (post-adaptation, what gets sent); NULL for
-- home/mentions/author sources.
query text,
-- Canonical account ref for source_kind='author': '{provider}:{handle@host}'.
actor_ref text,
state text NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending','ready','stale','error','unsupported','rate_limited','budget_paused','disabled')),
last_error text,
last_polled_at timestamptz,
next_poll_at timestamptz,
consecutive_failures integer NOT NULL DEFAULT 0 CHECK (consecutive_failures >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, id),
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT,
-- One source row per (column, polled identity); NULLs equal NULLs so a
-- keyless search source dedupes against itself (024 NULLS NOT DISTINCT
-- style). The SAME identity in two different columns is intentional (§3.3:
-- following the same account in two columns costs one poll) — the due
-- source scan collapses those rows into one poll and fans items out to
-- every column sharing the identity.
UNIQUE NULLS NOT DISTINCT (workspace_id, column_id, provider, source_kind, connection_id, query, actor_ref)
);
CREATE TRIGGER trg_column_sources_ws_immutable BEFORE UPDATE ON column_sources
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE column_sources ENABLE ROW LEVEL SECURITY;
ALTER TABLE column_sources FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON column_sources
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON column_sources TO sp_app;
-- Due-source scan (§3.4): workspace + next_poll_at with the disabled rows
-- partial-indexed away. Design §1.2 names this column_sources_due_idx on
-- (next_poll_at); workspace leads here because every scan is tenant-scoped
-- under RLS and the composite keeps each workspace's due window contiguous.
CREATE INDEX column_sources_due_idx
ON column_sources (workspace_id, next_poll_at)
WHERE state <> 'disabled';
-- Column fan-out listing (ColumnView.sources) and per-column refresh.
CREATE INDEX column_sources_column_idx ON column_sources (column_id);
-- Connection → sources (the degenerate platform_feed lookup in the scheduler
-- and the /feeds per-connection store fallback).
CREATE INDEX column_sources_connection_idx
ON column_sources (connection_id)
WHERE connection_id IS NOT NULL;
-- ---------------------------------------------------------------------------
-- feed_items — ingested items (§1.2): persisted depth for keyset pagination
-- and cross-column dedup; Redis stays the hot read-through cache in front.
-- ---------------------------------------------------------------------------
CREATE TABLE feed_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- sha256(workspace_id | provider | canonical_remote_id) — see §3.3; the
-- canonicalization rule is app-side, the key is opaque to the DB.
dedup_key text NOT NULL CHECK (dedup_key <> ''),
provider text NOT NULL CHECK (btrim(provider) <> '' AND length(provider) <= 64),
remote_id text NOT NULL CHECK (remote_id <> ''),
-- Connector-reported FeedItem.id; first writer wins, kept stable so a
-- cursor minted from the hot cache still tie-breaks identically when the
-- same page is later served from the store.
item_id text NOT NULL CHECK (item_id <> ''),
author_handle text NOT NULL CHECK (author_handle <> ''),
-- Canonical '{provider}:{handle@host}' when resolvable (P0c feedAuthorRef).
author_ref text,
-- Provider permalink; absolute credential-free http(s) only (P0c parseSafeHttpUrl).
url text CHECK (url IS NULL OR url ~ '^https?://'),
-- Provider timestamp — the deterministic keyset sort key.
created_at_remote timestamptz NOT NULL,
fetched_at timestamptz NOT NULL DEFAULT now(),
-- safe-content typed rich-text AST. NEVER raw HTML (§5.1) — the app layer
-- produces this through normalizeFeedItem; the CHECK only backstops shape.
content jsonb NOT NULL CHECK (jsonb_typeof(content) = 'object'),
-- Derived flat text (astToPlainText) for saved-search filtering/previews.
plain_text text NOT NULL,
-- [{kind:'image'|'video', proxyUrl, ...}] — proxy-minted only (§5.3).
media jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(media) = 'array'),
-- Finite numbers only (validated at ingest).
metrics jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metrics) = 'object'),
source_id uuid NOT NULL,
-- ToS expiry (YouTube Data-API items: fetched_at + 30d); sweeper is P2c.
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, dedup_key),
UNIQUE (id, workspace_id),
FOREIGN KEY (source_id, workspace_id) REFERENCES column_sources (id, workspace_id)
ON DELETE NO ACTION ON UPDATE RESTRICT
);
CREATE TRIGGER trg_feed_items_ws_immutable BEFORE UPDATE ON feed_items
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE feed_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE feed_items FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON feed_items
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON feed_items TO sp_app;
-- Keyset serving: GET /feeds/columns/:id/items pages (created_at_remote DESC,
-- dedup_key DESC) over this index (§1.4 "Cursor rule").
CREATE INDEX feed_items_ws_time_idx
ON feed_items (workspace_id, created_at_remote DESC, dedup_key DESC);
-- Store backfill for /feeds per connection and per-source item listing.
CREATE INDEX feed_items_source_idx ON feed_items (source_id, created_at_remote DESC);
-- Expiry sweeper (P2c) pre-indexed here so the later slice adds no DDL.
CREATE INDEX feed_items_expiry_idx ON feed_items (expires_at) WHERE expires_at IS NOT NULL;
-- ---------------------------------------------------------------------------
-- feed_item_columns — which columns show an item (§1.2 table 4). An item
-- ingested for a source identity is visible to every column sharing that
-- identity; rows are written by poll-time fan-out with ON CONFLICT DO NOTHING.
-- ---------------------------------------------------------------------------
-- stream_columns (021) has only its surrogate PK; migrations < 025 are frozen,
-- so the tenant-composite keys the FKs below need are added here — additive
-- unique indexes on supersets of existing keys, so no uniqueness changes.
CREATE UNIQUE INDEX IF NOT EXISTS stream_columns_id_workspace_key
ON stream_columns (id, workspace_id);
CREATE TABLE feed_item_columns (
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
item_id uuid NOT NULL,
column_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (item_id, column_id),
FOREIGN KEY (item_id, workspace_id) REFERENCES feed_items (id, workspace_id)
ON DELETE CASCADE ON UPDATE RESTRICT,
FOREIGN KEY (column_id, workspace_id) REFERENCES stream_columns (id, workspace_id)
ON DELETE CASCADE ON UPDATE RESTRICT
);
CREATE TRIGGER trg_feed_item_columns_ws_immutable BEFORE UPDATE ON feed_item_columns
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE feed_item_columns ENABLE ROW LEVEL SECURITY;
ALTER TABLE feed_item_columns FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON feed_item_columns
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, DELETE ON feed_item_columns TO sp_app;
-- Serving index: the items endpoint joins column_id → item_id before the
-- keyset filter; the PK alone would force an item_id-ordered probe.
CREATE INDEX feed_item_columns_column_idx ON feed_item_columns (column_id, item_id);
-- ---------------------------------------------------------------------------
-- host_budgets — applied verbatim from @tactical-media-broadcast-hub/egress
-- migrations/001_host_budgets.sql (WP7/WP8). Infrastructure state shared by
-- all egress callers; deliberately NOT tenant/RLS scoped (one IDNA lowercase
-- host:port key across callers and host classes — the whole point is that
-- monitor, feeds and any other caller of the same host share one bucket).
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS host_budgets (
host text PRIMARY KEY CHECK (host <> '' AND host = lower(host)),
capacity double precision NOT NULL CHECK (capacity >= 1 AND capacity < 'Infinity'::double precision),
-- Tokens per fixed 10-second interval (fractional tokens support continuous refill).
refill_per_interval double precision NOT NULL CHECK (refill_per_interval > 0 AND refill_per_interval < 'Infinity'::double precision),
tokens double precision NOT NULL CHECK (tokens >= 0 AND tokens <= capacity),
-- A future timestamp parks the bucket: no refill/debit until this deadline.
updated_at timestamptz NOT NULL DEFAULT clock_timestamp() CHECK (isfinite(updated_at))
);
COMMENT ON TABLE host_budgets IS 'Infrastructure egress budgets; one IDNA lowercase host:effective-port key across callers and host classes. Retain while host is in use.';
GRANT SELECT, INSERT, UPDATE ON host_budgets TO sp_app;
-- ---------------------------------------------------------------------------
-- RBAC seed: streams.columns.manage (design §1.4 — granted to Owner/Admin/
-- Editor by default; reads stay on draft.view). 002's unnest + ON CONFLICT
-- pattern so the seed is re-runnable and never fights a later role edit.
-- ---------------------------------------------------------------------------
INSERT INTO role_permissions (role, permission)
SELECT 'Owner', 'streams.columns.manage'
UNION ALL SELECT 'Admin', 'streams.columns.manage'
UNION ALL SELECT 'Editor', 'streams.columns.manage'
ON CONFLICT DO NOTHING;

View file

@ -0,0 +1,186 @@
-- 027: content intake firehose phase 1 (WP15+ firehose research §1/§2/§4/§5.3,
-- decision record docs/decisions/2026-10-09-intake-posture.md — Tier-1
-- sanctioned sources only, metadata-first).
--
-- NOTE on the number: this slice was specced as "026_intake.sql (next free)"
-- but 025_archive_search and 026_columns_p1 already exist, so intake lands as
-- 027 — the next free slot at authoring time.
--
-- Two tables:
--
-- intake_sources the per-workspace subscription registry (§4.4 sketch,
-- tenant-scoped per the phase-1 dedup note in §2.4: dedup
-- happens WITHIN a workspace; the global item registry +
-- claims split is the phase-1.5 upgrade).
-- intake_items the harvested rows. Dedup is CONTENT-ADDRESSED:
-- UNIQUE (workspace_id, content_hash) where content_hash is
-- sha256(normalized plain text + media origin identity), so
-- the same statement arriving via RSS and a Mastodon boost
-- (or the same URL watched twice) stores once. Storage-level
-- dedup stays in the S3 content-addressed layer.
--
-- `content` is the P0c safe-content rich-text AST — the ONLY render source;
-- adapters never hand raw provider HTML through (apps/api/src/feeds/
-- feed-normalize.ts funnels every item). `text` is the derived flat text
-- (astToPlainText) that FTS + pg_trgm index.
--
-- FTS mirrors 025's design: 'simple' config (multilingual firehose, no
-- stemming dictionary is "right"), websearch_to_tsquery in the API ORed with
-- pg_trgm similarity for typo tolerance; tsvector maintained by trigger
-- because edits/telegram revisions must reindex.
--
-- RLS / NULLIF / reject_workspace_change / sp_app grants follow the
-- 001/015/026 conventions: no app.workspace_id => NULL => no rows; FORCE
-- binds sp_app too. CREATE EXTENSION requires privileges sp_app lacks; this
-- migration runs as the table owner only (025 note).
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- ---------------------------------------------------------------------------
-- intake_sources
-- ---------------------------------------------------------------------------
CREATE TABLE intake_sources (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
-- Tier-1 sanctioned intake kinds (decision record). bluesky_keyword is a
-- stub seam in this slice — the Jetstream consumer is a later slice, so the
-- scheduler parks those rows in health='unsupported' rather than polling.
kind text NOT NULL CHECK (kind IN ('rss','mastodon_tag','mastodon_account','bluesky_keyword',
'telegram_channel','peertube_channel','youtube_channel','web_watch')),
-- Per-kind identity: url (rss/web_watch), tag (mastodon_tag), instance +
-- accountId (mastodon_account), keywords[] (bluesky_keyword), channelId
-- (telegram_channel / youtube_channel), handle + instance (peertube_channel).
-- Validated app-side per kind; the DB only backstops "is an object".
params jsonb NOT NULL CHECK (jsonb_typeof(params) = 'object'),
-- sha256 of the canonically serialized params — the stable identity half of
-- UNIQUE (workspace_id, kind, params_hash). Canonicalization is app-side.
params_hash text NOT NULL CHECK (params_hash ~ '^[0-9a-f]{64}$'),
-- 0 = push-only (telegram webhook bridge, bluesky Jetstream): never due.
poll_interval_sec integer NOT NULL DEFAULT 300 CHECK (poll_interval_sec >= 0),
-- Adapter carry: etag / lastModified / lastContentHash / lastRemoteRef.
state jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(state) = 'object'),
health text NOT NULL DEFAULT 'active'
CHECK (health IN ('active','backoff','error','paused','unsupported')),
last_error text,
last_polled_at timestamptz,
next_poll_at timestamptz,
consecutive_failures integer NOT NULL DEFAULT 0 CHECK (consecutive_failures >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, id),
UNIQUE (workspace_id, kind, params_hash)
);
CREATE TRIGGER trg_intake_sources_ws_immutable BEFORE UPDATE ON intake_sources
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE intake_sources ENABLE ROW LEVEL SECURITY;
ALTER TABLE intake_sources FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON intake_sources
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON intake_sources TO sp_app;
-- Due-source scan (the scheduler's loop): workspace + next_poll_at with the
-- non-pollable rows partial-indexed away.
CREATE INDEX intake_sources_due_idx
ON intake_sources (workspace_id, next_poll_at)
WHERE health NOT IN ('paused','unsupported');
-- Listing / "which sources feed this item".
CREATE INDEX intake_sources_kind_idx ON intake_sources (workspace_id, kind);
-- ---------------------------------------------------------------------------
-- intake_items
-- ---------------------------------------------------------------------------
CREATE TABLE intake_items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE ON UPDATE RESTRICT,
source_id uuid NOT NULL,
-- Platform-native id for the source (guid, status id, yt:video:…, tg chat:id,
-- url#hash for web_watch diffs).
remote_ref text NOT NULL CHECK (btrim(remote_ref) <> '' AND length(remote_ref) <= 2048),
-- sha256(NFKC + whitespace-collapsed AST plain text + sorted media origin
-- identity). THE dedup key: UNIQUE (workspace_id, content_hash).
content_hash text NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'),
published_at timestamptz NOT NULL,
-- Flat text derived from `content` at ingest (astToPlainText) — the FTS and
-- pg_trgm corpus. `text` is non-reserved; kept as the specced column name.
text text NOT NULL DEFAULT '',
-- P0c safe-content rich-text AST (doc/paragraph/inline). NEVER raw HTML.
content jsonb NOT NULL CHECK (jsonb_typeof(content) = 'object'),
-- [{kind, proxyUrl}…] for pull adapters (feed-normalize proxied media) or
-- [{kind, storageKey, …}] for the Telegram archive bridge (content-addressed
-- S3 keys already harvested by apps/api/src/archive).
media_refs jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(media_refs) = 'array'),
-- {authorHandle, authorRef, url, title, provider, …kind extras}
attribution jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(attribution) = 'object'),
fetched_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (id, workspace_id),
-- Content-addressed dedup (spec §2.4 layer 2): first writer wins, every
-- later delivery of the same content no-ops.
UNIQUE (workspace_id, content_hash),
FOREIGN KEY (source_id, workspace_id) REFERENCES intake_sources (id, workspace_id)
ON DELETE CASCADE ON UPDATE RESTRICT
);
-- Maintained by trigger below; declared here so the column exists for the
-- index + trigger DDL in one place.
ALTER TABLE intake_items ADD COLUMN search_vector tsvector NOT NULL DEFAULT '';
CREATE TRIGGER trg_intake_items_ws_immutable BEFORE UPDATE ON intake_items
FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE intake_items ENABLE ROW LEVEL SECURITY;
ALTER TABLE intake_items FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON intake_items
USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
GRANT SELECT, INSERT, UPDATE, DELETE ON intake_items TO sp_app;
-- Per-source listing (GET /intake/items?sourceId=) keyset order.
CREATE INDEX intake_items_source_idx
ON intake_items (workspace_id, source_id, published_at DESC, id DESC);
-- Whole-workspace newest-first (GET /intake/items without a source filter).
CREATE INDEX intake_items_time_idx
ON intake_items (workspace_id, published_at DESC, id DESC);
-- ---------------------------------------------------------------------------
-- FTS (025 pattern)
-- ---------------------------------------------------------------------------
-- String-valued attribution keys worth indexing as metadata.
CREATE FUNCTION intake_attribution_text(attribution jsonb)
RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT coalesce(string_agg(value, ' '), '')
FROM jsonb_each_text(attribution)
WHERE key IN ('authorHandle','authorRef','title','channelTitle','channelUsername',
'author','authorSignature','forwardFromChat','forwardFromUser','handle')
$$;
CREATE FUNCTION intake_items_search_vector() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('simple', coalesce(NEW.attribution->>'title', '')), 'A')
|| setweight(to_tsvector('simple', intake_attribution_text(NEW.attribution)), 'B')
|| to_tsvector('simple', coalesce(NEW.text, ''));
RETURN NEW;
END $$;
CREATE TRIGGER trg_intake_items_search
BEFORE INSERT OR UPDATE OF text, attribution
ON intake_items FOR EACH ROW
EXECUTE FUNCTION intake_items_search_vector();
-- Exact/lexical path. fastupdate stays on explicitly: GIN insert cost is the
-- watch item at firehose write rates (research §2.7).
CREATE INDEX intake_items_fts ON intake_items USING gin (search_vector) WITH (fastupdate = on);
-- Fuzzy path (typo tolerance) + ILIKE support on the text corpus.
CREATE INDEX intake_items_text_trgm ON intake_items USING gin (text gin_trgm_ops);

View file

@ -0,0 +1,79 @@
-- 028: instance-level admin settings — content-policy posture for intake.
--
-- The intake-posture decision record
-- (docs/decisions/2026-10-09-intake-posture.md) grades the intake tiers by
-- legal/ToS risk:
--
-- YouTube channel RSS + metadata = safe (keyless, no quota, no ToS risk)
-- YouTube full harvest (yt-dlp) = ToS risk — per-source opt-in ONLY
-- (027's DEFAULT_MEDIA_POLICY already
-- parks youtube_channel at 'metadata');
-- this switch is the instance-wide gate
-- the per-source opt-in is subordinate to
-- YouTube broadcast playlists = CC-only (Creative Commons licensed
-- content only) — safe to rebroadcast
-- Locked platforms (X/IG/TikTok) = metadata-only via sanctioned
-- connectors; a bounded read-only pool is
-- the decision record's Tier-2 "small
-- bounded pool, later" option — opt-in
--
-- Those posture choices are INSTANCE-level (one deployment = one collective,
-- one egress IP, one legal exposure), not per-workspace: no workspace admin
-- may widen them for everyone. Hence this table is a system table:
--
-- * no workspace_id — deliberately. It is not a tenant row.
-- * no RLS — there is nothing to isolate BY. Access control is at the API
-- seam (SettingsService runs as sp_app; only Owner/Admin roles reach the
-- setters through the controller's role check). NOT a grant-surface for
-- tenant sessions: the app pool is the only grantee.
--
-- key-value shape (key TEXT PK, value TEXT NOT NULL) per task spec: settings
-- are few, admin-facing and read as a whole — typed decoding lives in
-- apps/api/src/settings/settings.service.ts (the ONE place that interprets
-- values; nothing else parses these strings), values are CHECK-constrained
-- to the exact literal the service layer understands so a stray UPDATE can
-- never park an undecodable row in the table.
BEGIN;
CREATE TABLE instance_settings (
key text PRIMARY KEY,
value text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
-- Legal-exposure switches must never hold anything but the literals the
-- service layer decodes; unknown keys are equally rejected so a typo can't
-- fork the settings surface.
CONSTRAINT instance_settings_key_allowed CHECK (key IN (
'youtube_full_harvest', 'youtube_cc_only_broadcast',
'scrape_metadata_only', 'scrape_bounded_pilot', 'intake_posture'
)),
CONSTRAINT instance_settings_bool_value CHECK (
key NOT IN ('youtube_full_harvest', 'youtube_cc_only_broadcast',
'scrape_metadata_only', 'scrape_bounded_pilot')
OR value IN ('true', 'false')
),
CONSTRAINT instance_settings_posture_value CHECK (
key <> 'intake_posture'
OR value IN ('metadata_only', 'community_submissions', 'bounded_pilot')
)
);
-- ---------------------------------------------------------------------------
-- Seed defaults (the safe posture — matches the 2026-10-09 decision record:
-- "metadata-only + community submissions + Tier-1 sanctioned sources").
-- ON CONFLICT DO NOTHING keeps re-runs and later re-seeds idempotent.
-- ---------------------------------------------------------------------------
INSERT INTO instance_settings (key, value) VALUES
('youtube_full_harvest', 'false'), -- yt-dlp OFF at the gate
('youtube_cc_only_broadcast', 'true'), -- CC-only broadcast filter
('scrape_metadata_only', 'true'), -- locked platforms: metadata
('scrape_bounded_pilot', 'false'), -- no bounded X pool
('intake_posture', 'metadata_only') -- overall posture
ON CONFLICT (key) DO NOTHING;
-- System table: sp_app is the ONLY grantee (003's default privileges already
-- cover it, but the explicit grant documents intent the way 006/008/009 do).
GRANT SELECT, INSERT, UPDATE, DELETE ON instance_settings TO sp_app;
COMMIT;