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/.
477 lines
26 KiB
SQL
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;
|