T-HUB/apps/api/migrations/024_chatgov.sql
glass beadian aa5e262f3a 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/.
2026-10-10 11:56:42 +00:00

477 lines
26 KiB
SQL

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