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:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
477
apps/api/migrations/024_chatgov.sql
Normal file
477
apps/api/migrations/024_chatgov.sql
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue