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/.
38 KiB
Chat Governance Design — Polls · Approval-Gated Posting · Forum Topics · Multi-Account Policies · Telegram Bot Control
Design doc: 2026-10-09 · Codebase: apps/api/src/live-chat/**, packages/connector-telegram/src, apps/api/src/publishing, apps/api/src/media-control.
0. Decision summary (the ten calls this design makes)
| # | Decision |
|---|---|
| D1 | Polls are OURS, not Telegram's. Ballots live in poll_votes; the Telegram card is an inline-keyboard mirror edited in place. Native sendPoll is allowed only for audience sentiment (≤600 s, anonymous, aggregate-only) and can never gate a post. Rationale: 600 s ceiling, anonymous-by-default, no voter roster API, no reopen (polls report §1). |
| D2 | Chat-governance bot = grammY 1.46 gateway module inside the API (apps/api/src/chat-gov/), webhook in prod (secret per binding), @grammyjs/runner long-poll in dev. Publishing sends continue to flow through the existing connector-telegram + Temporal pipeline — the gateway never bulk-posts. |
| D3 | Forum topics are a binding registry, not a mirror of Telegram state. Bot API has no list-topics → we persist message_thread_id at creation; enabling forum mode stays a human/MTProto runbook step (documented, no userbot in v1). |
| D4 | Posting rules are data, evaluated at three gates (E1/E2/E3) with precedence topic > contentType > account > workspace, whole-block replacement, compiled snapshot + policy_version pinned at save and at every decision. E3 pre-dispatch re-check is mandatory. |
| D5 | Poll-pass NEVER side-channels a platform post. Pass → approve-equivalent transition on a draft (system actor system:chat-poll-gate) → existing schedule/publish machinery → E3. Chat-originated posts are auto-materialized as drafts first. |
| D6 | Chat commands map 1:1 onto existing DirectorOperation (`take |
| D7 | Destructive commands are confirmation-gated: inline CONFIRM/ABORT, 30 s TTL, requester-only callback, dry-run diff echo, nonce; ephemeral replace_callback_query_message (Bot API 10.3) when available. |
| D8 | Every account/destination evaluates its own policy. One draft → N destination decision records; partial fan-out (A sent, B blocked) is a first-class outcome. |
| D9 | Votes from ineligible users are stored, never counted (GitLab pattern) and surfaced with INELIGIBLE_ACTOR; eligibility is re-read at close time. |
| D10 | Anti-abuse reuses what exists: SendGovernors/TokenBucket in front of every outbound mirror/ack, grammY throttler (30/s global, 1/s per chat, 20/min group) with bypassThrottler for acks, update_id dedupe, per-user per-command-class token bucket, and the existing live_chat_requests idempotency pattern for close→publish. |
1. Feature set
1a. Polls in bridged chats — configurable pass criteria
Subject of a poll. A poll gates a thing: a draft (existing DraftPost), a chat post (text/media composed via /post → materialized as a draft, D5), or nothing (free-standing sentiment/procedural vote). Polls are created from the web UI, the overlay, or /post+/poll in a bound chat.
Pass criteria — compiled rule object (stored on the poll at open; never re-resolved mid-flight):
{
"version": 1,
"mode": "quorum", // immediate | quorum | admin_only
"arms": [
{ "kind": "admins", "x": 2 }, // x of workspace Admin/Owner set
{ "kind": "participants","x": 3, "of": "observed" } // of: observed | eligible | none(count-only)
],
"join": "all", // all (AND, default) | any (OR)
"allowVeto": true, // one veto fails regardless of quorum
"minDwellSeconds": 45, // early close only when quorum ∧ dwell (anti-railroading)
"timebox": { "duration": "PT2H", "onExpire": "reject" }, // approve | reject | escalateOwner
"anonymity": "named", // named | anonymous (never mixed silently)
"allowSelfApproval": false, // draft author's yes is stored, not counted
"resetOnChange": true, // draft edit ⇒ approvals reset, poll re-arms
"dryRun": false // evaluate + log, never block/fire
}
- admins arm: eligibility = workspace membership role ∈ {Owner, Admin} (or
chat_identities.role_override≥ that) evaluated at close time (TOCTOU guard). - participants arm:
of:observed= distinct chat identities seen in the channel sinceopens_at(roster derived fromlive_chat_messages— we already own that table);of:eligible= explicit roster bound to the topic/channel;of:none= pure count. - mixed = conjunctive arms (
join:"all"), the GitHub count+code-owner shape; each arm keeps its ownx. - State machine:
open → passed | failed | vetoed | quorum_unreachable | cancelled.quorum_unreachablefires eagerly when remaining eligible voters < needed (grey-the-card UI). Closed is terminal; a re-vote is a new poll withparent_poll_id. - Close events: (1) quorum ∧ minDwell → early close; (2)
closes_attimebox expiry →onExpireaction; (3) veto → immediate. Evaluation is event-driven (every ballot re-evaluates); the timebox is the only timer (TemporalpollTimeboxworkflow + lazy re-eval on read). - Idempotent close: state flip and downstream trigger live in one transaction guarded by
poll_outboxexactly-once claim; double-close cannot double-publish. - Native Telegram polls:
/sentiment <question> ; <opt1> ; <opt2>…sendssendPoll(anonymous,open_period≤600 s), aggregate mirrored into the overlay card, never linked to a draft. Capability-gated:poll.sentiment.
1b. Poll-pass → automatic posting bot
Two wiring modes per destination policy:
- Gate mode (default): poll arms = the E2 gate on a draft. Pass → approve transition (system actor, §4) → E3 per destination → connector publish (
sendMessage/sendPhoto/sendMediaGroup,message_thread_idwhen topic-bound) →message_idstored for edit/withdraw. Fail/veto →rejectwith poll reference. - Echo mode (small chats): poll subject is a chat post with no scheduled fan-out; pass posts the same content back into the originating chat/topic via the announce binding. Internally still materializes a draft + one destination (auditability, D5) — the "direct post" is a fan-out of size 1.
Poll-pass can also trigger compose: /post with attachments creates the draft in editing; on pass with triggerCompose: true the UI opens the composer pre-loaded with the approved draft for crosspost (WP15 path: fetch media → asset → draft quoting original → normal policies).
1c. Forum topics as subchats
topic_bindingsregistry:(workspace, connection, chat_id, message_thread_id)→ roleannounce | control | audit, plus assigned accounts and linked virtual channel. A topic binding behaves as a subchat: it has its own live-chat channel key (its own seq/fanout topic), its own posting policy (highest precedence), its own member roster.- Bot operations: create (
createForumTopic, needscan_manage_topics), rename (editForumTopic), close/reopen (closeForumTopic/reopenForumTopic), plusunpinAllForumTopicMessageson archive.deleteForumTopicis never exposed from chat (deletes all messages; console-only with confirmation). Enabling forum mode on a supergroup: runbook step for a human admin (Bot API cannot; see research §2). Topic listing/backfill would need MTProto — deferred; our registry is authoritative for what we created. - Accounts assigned to topics:
topic_bindings.account_ids[]— a destination account may be bound to zero+ topics; posts to that account ridemessage_thread_idof the binding. Unbound announce channels post to the channel root. - Auto-topic-per-series: on first approval pass for a show/series, broadcaster auto-creates a color-coded topic (icon colors from the fixed Telegram palette) and binds it; archive closes it. An audit topic is auto-created per workspace and kept append-only (every governance decision mirrored there).
1d. Multiple accounts per platform, each with posting rules
- Any number of connections per platform (each Telegram connection = one bot token; Mastodon = one handle; …). Policies anchor to (platform, accountId) — the account is the policy anchor, platform is an attribute.
- Modes:
immediate(no gate, still audited) ·quorum(arms as §1a;admins/participants/mixed) ·admin_only(single-admin gate:quorum:[{kind:admins,x:1}], no participant arm, poll optional). - Cross-cutting knobs per policy:
timebox.onExpire,allowSelfApproval:false,resetOnChange:true,dryRun,fallback:block. - Precedence:
topic > contentType > account > workspace default, most-specific-wins, whole-block replacement (no field merges). Compiled at save-time into a resolved snapshot +policy_versionhash persisted on the binding; evaluation reads only snapshots. Every rule supportsdryRun(shadow) for onboarding a new policy. - Fan-out law (D8): one draft → N destinations → N independent decision records; "fully sent" = all destinations fired.
1e. Telegram bot broadcast control
Command grammar (control binding / locked #control topic only):
| Command | Maps to | Permission | Confirm? |
|---|---|---|---|
/tally |
read director state | director.view |
no |
/pst <sceneId> |
{type:"pst-select"} |
director.take |
no |
/take [sceneId] |
{type:"take"} |
director.take |
no |
/cut [sceneId] |
{type:"cut"} |
director.take |
yes |
/lock on |
{type:"lock",enabled:true} |
director.lock_program |
no (protective) |
/lock off / /unlock |
{type:"lock",enabled:false} |
director.lock_program |
yes |
| `/override <mode | off>` | {type:"override"} |
director.override_auto |
/start /stop |
desired RUNNING/STOPPED | director.take |
stop: yes |
/topics new|close|list |
topic registry ops | chatgov.manage |
no |
/post <text> [+media reply] |
draft + poll | drafts.create |
no |
/poll (reply) |
open vote on a draft/chat post | chatgov.vote |
no |
/sentiment … |
native sendPoll | chatgov.sentiment |
no |
/register |
claim identity → console approval | — | n/a (approval in console) |
/help |
— | — | no |
Channel binding: each control binding carries linked_channel_id (the virtual channel commands act on); /tally replies with program/preview/lock/override snapshot. Locked channel ⇒ /take//cut refused with the interlock reason (state machine enforced server-side in media-control, chat only displays).
Confirmation UX (destructive): (1) dry-run diff echo ("program: SRC-A → SRC-B · ch s27-main"); (2) inline CONFIRM / ABORT keyboard, callback_data = cf:<nonce> (≤64 B); (3) callback accepted only from the requesting from.id; (4) 30 s TTL → panel self-erases via replace_callback_query_message (10.3) or edits to "expired"; (5) answerCallbackQuery ack; (6) execution goes through the same idempotent director() call the console uses, with the chat nonce as Idempotency-Key; (7) result + actor mirrored to the audit topic.
2. Data model
All tables: workspace_id uuid NOT NULL, RLS ENABLE+FORCE with the standard tenant policy (migration lint enforces), all access via withTenant, each added to the guards-bypassed RLS denial suite (WP2 convention).
-- 001_chatgov.sql (sketch)
CREATE TABLE polls (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
channel_id uuid, -- live-chat channel (nullable: console-only poll)
topic_binding_id uuid, -- vote scoped to a subchat when set
subject_kind text NOT NULL CHECK (subject_kind IN ('draft','chat_post','none')),
draft_id uuid, -- FK drafts(id) when subject_kind='draft'
question text NOT NULL, -- 1..300
parent_poll_id uuid, -- revote lineage
rule jsonb NOT NULL, -- compiled §1a object (snapshot at open)
policy_version text NOT NULL, -- hash of rule at open
state text NOT NULL DEFAULT 'open'
CHECK (state IN ('open','passed','failed','vetoed','quorum_unreachable','cancelled')),
opens_at timestamptz NOT NULL DEFAULT now(),
closes_at timestamptz NOT NULL, -- opens_at + timebox.duration
closed_at timestamptz,
close_reason text, -- quorum_early | timebox | veto | cancelled
outbox_state text NOT NULL DEFAULT 'none' -- none|claimed|done (exactly-once close)
CHECK (outbox_state IN ('none','claimed','done')),
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX polls_open_one_per_draft ON polls (workspace_id, draft_id)
WHERE state = 'open' AND draft_id IS NOT NULL; -- one open gate per draft
CREATE INDEX polls_ws_state ON polls (workspace_id, state, closes_at);
CREATE TABLE poll_votes (
poll_id uuid NOT NULL REFERENCES polls(id),
workspace_id uuid NOT NULL,
voter_kind text NOT NULL CHECK (voter_kind IN ('user','chat_identity','system')),
voter_user_id uuid, -- resolved workspace user, when known
voter_remote_id text NOT NULL, -- provider:from.id (numeric for TG)
voter_handle text NOT NULL,
ballot text NOT NULL CHECK (ballot IN ('yes','no','veto','abstain')),
eligible boolean NOT NULL DEFAULT true, -- eligibility AT CAST (informational)
counted boolean NOT NULL DEFAULT true, -- ineligible votes stored, not counted
ineligible_reason text, -- INELIGIBLE_ACTOR | SELF_APPROVAL_DENIED
cast_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (poll_id, voter_remote_id) -- idempotent upsert; revote = replace ballot
);
CREATE INDEX poll_votes_poll ON poll_votes (poll_id, counted);
CREATE TABLE posting_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
scope text NOT NULL CHECK (scope IN ('workspace','account','content_type','topic')),
account_id uuid, -- connection id; NULL for workspace
topic_binding_id uuid, -- when scope='topic'
content_type text, -- 'alert' | 'video' | ... when scope='content_type'
policy jsonb NOT NULL, -- §1a-shaped rule (minus subject)
policy_version text NOT NULL, -- compiled snapshot hash at save
dry_run boolean NOT NULL DEFAULT false,
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
superseded_by uuid,
UNIQUE (workspace_id, scope, account_id, topic_binding_id, content_type)
);
-- Effective policy resolution is done at SAVE time into:
CREATE TABLE posting_policy_snapshots (
workspace_id uuid NOT NULL,
destination_kind text NOT NULL, -- 'account' | 'topic'
account_id uuid NOT NULL,
topic_binding_id uuid,
effective jsonb NOT NULL,
policy_version text NOT NULL,
resolved_from jsonb NOT NULL, -- audit: which scopes contributed
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (workspace_id, account_id, topic_binding_id)
);
CREATE TABLE topic_bindings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
connection_id uuid NOT NULL, -- telegram connection (bot)
chat_id text NOT NULL, -- numeric -100… preferred (usernames drift)
message_thread_id text, -- NULL = channel root / whole group
binding_type text NOT NULL CHECK (binding_type IN ('announce','control','audit')),
name text NOT NULL, -- 1..128 (Telegram topic name limit)
icon_color integer, -- one of the 6 fixed TG colors
state text NOT NULL DEFAULT 'active' CHECK (state IN ('active','closed','archived')),
account_ids uuid[] NOT NULL DEFAULT '{}', -- accounts that post here
linked_channel_id uuid, -- control: VC this binding commands
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, connection_id, chat_id, message_thread_id)
);
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;
CREATE TABLE chat_identities (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
provider text NOT NULL,
remote_user_id text NOT NULL, -- immutable numeric TG id — NEVER username
user_id uuid, -- linked workspace user (after /register approval)
display_name text,
status text NOT NULL DEFAULT 'pending' -- pending|approved|rejected|banned
CHECK (status IN ('pending','approved','rejected','banned')),
approved_by uuid,
first_seen_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, provider, remote_user_id)
);
CREATE TABLE chat_command_audit (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
update_id bigint NOT NULL, -- Telegram update id (dedupe)
binding_id uuid, -- topic binding command arrived on
provider text NOT NULL,
remote_user_id text NOT NULL,
user_id uuid, -- resolved identity, if any
command text NOT NULL, -- '/cut'
args jsonb NOT NULL DEFAULT '{}',
permission text, -- RBAC permission required
decision text NOT NULL CHECK (decision IN ('accepted','rejected','unauthorized','unknown_command','rate_limited')),
reason text, -- machine code: INELIGIBLE_ACTOR | LOCKED_CHANNEL | CONFIRM_EXPIRED | ...
nonce text, -- confirmation nonce when confirm-gated
idempotency_key text, -- chat nonce used as director key
result_ref jsonb, -- receipt fingerprint / poll id / draft id
received_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, provider, update_id)
);
CREATE INDEX chat_command_audit_ws_time ON chat_command_audit (workspace_id, received_at DESC);
-- Governance decision records (one per destination per evaluation, §4)
CREATE TABLE gate_decisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
draft_id uuid NOT NULL,
destination jsonb NOT NULL, -- {platform, accountId, topicBindingId?}
gate text NOT NULL CHECK (gate IN ('E1','E2','E3')),
policy_version text NOT NULL,
outcome text NOT NULL CHECK (outcome IN ('fired','blocked','pending')),
inputs jsonb NOT NULL, -- approvals, poll tally, quorum gaps
reason_codes text[] NOT NULL DEFAULT '{}',
actor text NOT NULL, -- 'system:gate-e3' | 'user:<id>' | 'chat_poll:<pollId>'
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX gate_decisions_draft ON gate_decisions (workspace_id, draft_id, gate);
-- Poll mirror bookkeeping (card = edited, never re-sent)
CREATE TABLE poll_mirrors (
poll_id uuid NOT NULL,
workspace_id uuid NOT NULL,
binding_id uuid NOT NULL,
message_remote_id text NOT NULL, -- for editMessageText
last_edit_at timestamptz NOT NULL DEFAULT now(),
last_render_hash text NOT NULL, -- edit-on-change guard
PRIMARY KEY (poll_id, binding_id)
);
Migration sketch — single forward migration 001_chatgov.sql: create the tables above + RLS enable/force + tenant policies + indexes; register retention classes (poll_votes, chat_command_audit, gate_decisions append-hot, monthly-partition candidates per WP2 convention; polls/posting_rules stay unpartitioned FK parents). Rollback = drop schema objects (no data migration in v1). Decision-record events additionally append to the existing audit_append chain (poll.*, policy.*, chatcmd.* actions) so governance lands in the tamper-evident spine.
3. Bot architecture
Library: grammY 1.46 (+ @grammyjs/transformer-throttler, @grammyjs/conversations, @grammyjs/menu, @grammyjs/runner). TS-first, tracks Bot API 10.3, throttler ships compliant budgets (30/s global · 1/s per chat · 20/min group) and honors 429 retry_after; bypassThrottler fast-lanes command acks. Telegraf is stale (2024); node-telegram-bot-api is an immature RC — both rejected per research.
Topology — module, not a new service. apps/api/src/chat-gov/:
chat-gov/
telegram-gateway.ts grammY Bot per approved token; webhook in prod
(POST /webhooks/chatgov/:bindingId, secret_token per
connection), runner+long-poll in dev; 1 poller per token
command-router.ts grammar → RBAC check → confirm gate → control-plane call
poll-mirror.ts poll card render/edit (edit-on-change, 2–5 s throttle)
poll-service.ts open/vote/close/evaluate; emits governance WS frames
policy-service.ts rule CRUD + compile to posting_policy_snapshots
gate-evaluator.ts E1/E2/E3 evaluation + decision records
chat-gov.module.ts
- Bridge to live-chat WS: inbound TG messages in bound chats →
NormalizedChatEvent→ existingLiveChatHubService.ingest(same dedupe, seq, ring buffer); a topic binding supplies its ownchannel_idso each subchat sequences and fans out independently on the existingchat:<ws>:<channel>Redis topic. Outbound,poll-mirrorand acks go throughSendGovernors-wrapped transports — a newChatTransportimpl for telegram whosesend/editconsume the same token buckets. Governance frames ride the same fanout askind:"chat": newkind:"poll"(snapshot + delta),kind:"poll_closed",kind:"tally"— same subscribe/ack/resume contract, server-side debounce (≥250 ms coalesce) so vote storms don't flood the overlay. - Capability plumbing: extend
LiveChatCapabilityinpackages/core/src/chat-contract.tswithpoll: "none" | "vote" | "sentiment"andtopics: boolean;capability-probe.tsverifies a callback_query round-trip before advertisingvote(inline keyboards are always sendable; the callback is what must work). - Command router pipeline (order fixed): dedupe on
update_id→ resolve binding (chat_id + thread → topic_bindings) → resolve identity (chat_identities, numeric id) → RBAC map (identity.user → membership role → permission via existing permission table; role sources are ours, TG admin flags are display hints only) → per-user per-command-class token bucket → parse/validate args → confirm gate for destructive → call control plane (media-control.director(),publishing, or chat-gov service) with chat nonce asIdempotency-Key→chat_command_auditrow → reply + audit-topic mirror. - Permission mapping:
/tally→director.view;/pst|/take|/cut|/start|/stop→director.take;/lock→director.lock_program;/override→director.override_auto;/topics→chatgov.manage;/post→drafts.create;/poll→chatgov.vote;/register→self-service + console approval (chatgov.manageapproves). Unregistered identity ⇒ everything except/help,/register,/tallyisunauthorized. Unlinked control binding ⇒ commands rejected withNO_CHANNEL_BOUND. - Rate limiting, layered: grammY throttler (outbound API shape) →
SendGovernorstoken buckets (our policy: mirror edits ≤1 per 3 s per binding, priority lanes control > notify > bulk) → per-user per-command-class bucket (e.g./cut≤3/min) → 429 global backoff with jitter.poll-mirroredits only whenrender_hashchanges.
4. Quorum evaluation ↔ publishing pipeline
Draft states today: editing → in_review → approved → rejected → scheduled → published, with approved_hash/approved_revision pinned at approve time, and audit_append per transition. Chat governance plugs into exactly these seams — no parallel pipeline.
Gates (Argo sync-wave order):
| Gate | Where | Signal | Fires |
|---|---|---|---|
| E1 | review → approved transition |
RBAC approvals on the draft | Editor submits / console approve |
| E2 | Chat poll gate | polls.state outcome |
ballot cast (event-driven) or timebox (timer) |
| E3 | Immediately before each platform dispatch | compiled snapshot + approvals + poll state | connector publish call |
Flow for a quorum-gated destination:
- Draft created (console or
/post) with N destinations → each destination's effective snapshot resolved →gate_decisionsrows createdpending. - Poll opened (auto on submit when an arm requires chat signal, or manually): rule compiled from the destination's most-gating arm;
polls.rulesnapshot +policy_versionpinned. - Every ballot upserts
poll_votes(idempotent on(poll_id, voter_remote_id)) and re-evaluates in the same transaction: eligibility re-read, arms computed,quorum_unreachablechecked eagerly. Card edits flow through mirror throttle. - Close (quorum ∧ minDwell | timebox
onExpire| veto): claimoutbox_state(exactly-once), set final state, then in the same transaction: pass ⇒publishingServicetransitionin_review → approvedwith system actorsystem:chat-poll-gate:<pollId>; veto/fail ⇒→ rejectedwith reason. The poll id doubles as the idempotency key — replayed closes are no-ops.approved_hashpinning stays intact: a post-close draft edit re-validates at E3 and is blocked (RESET_ON_CHANGE/ hash mismatch). - E3 pre-dispatch (Temporal
scheduledPublishworker, per destination): re-read snapshot + approvals + poll state +approved_hash; evaluate; write thegate_decisionsoutcome (fired/blocked+ reason codes); only then connector publish. Partial fan-out is normal: A fires, B blocked ⇒ B's destination shows in the blocked queue with its reason codes and gap-to-quorum. - Audit trail:
poll.opened/voted/closed+policy.evaluated+release.fired/blockedall append viaaudit_append(tamper-evident chain, actor = system identity with poll ref); every decision record embedspolicy_version.admin_onlymode = same path with a 1-arm poll or an E1-only console approval.immediatemode = E1 minimal + E3 always.
Reason codes (machine-readable, attached to the blocked post object, not just logs): INSUFFICIENT_QUORUM (got x, need y, per arm) · INELIGIBLE_ACTOR · SELF_APPROVAL_DENIED · TIMEBOX_EXPIRED_AUTO_REJECT / ..._AUTO_APPROVE · RESET_ON_CHANGE · PENDING_POLL · VETOED · OUTSIDE_POLICY · POLICY_DRY_RUN · OVERRIDDEN_BY_OWNER · LOCKED_CHANNEL · NO_CHANNEL_BOUND · CONFIRM_EXPIRED.
5. UI spec
Poll card (one React component, three mount points: web chat pane, OBS/browser overlay, mobile).
- Header: question + author avatar + status chip (
collecting votes → quorum met → passed → publishingphase ribbon). - Body: horizontal tally bars per option, width = share, count + % right-aligned; named mode renders avatar stacks (max 5 +
+n) under each bar; anonymous renders a lock glyph and "votes hidden" — never mixed. - Quorum strip: visually distinct segmented meter per arm —
2/3 admins ●●○ · 1/3 participants ●○○(dots, not bars: a gate, not an option). Greyed card + "quorum unreachable — 1 eligible voter remaining" when eager-check trips. - Countdown chip on the card edge;
closing soon(<10% left) gets emphasis. Terminal state: bars freeze, winner highlighted, outcome ribbonPASS ✓ / FAIL ✗ / VETO ⛔, auto-collapse to one line in history after 60 s (overlay immediately). - Interaction: web/mobile tap a bar (or 👍/👎/veto buttons); Telegram inline keyboard
[👍 2] [👎 1] [⛔ veto]— the card, not chat messages, is the only surface that changes. Revote overwrites; keyboard row re-renders counts on edit. - Overlay mount: 320–400 px corner card, same component, fed by the same WS frames; server-debounced deltas; resolved state collapses to a single line.
Vote flow (mobile): tap → optimistic bar update (client) → WS confirm (authoritative, replaces optimistic) → haptic + checkmark on your bar. Offline/late frames: card always renders from the last authoritative snapshot (kind:"poll" snapshot after kind:"poll_delta" catch-up on resume).
Posting-rules editor (console, per account and per topic):
- Left: destination tree (platform → accounts → topics). Right: rule card with mode selector (
immediate / quorum / admin_only), arm builder rows (kind+x+of),jointoggle, veto switch, min-dwell, timebox duration +onExpireselect, anonymity, self-approval, reset-on-change, dry-run toggle with a badge. - Save action shows the resolved preview ("effective for @newsbot: 2-of-admins AND 3 participants, veto on, 2 h → auto-reject") before commit; the compiled
policy_versionhash is displayed. Every save writespolicy.revisedto the audit chain; edits requirechatgov.manage. - Blocked queue view: drafts blocked at E2/E3 as first-class cards with gap-to-quorum meter (live), reason-code chips, and actions (nudge voters / open re-vote with
parent_poll_id/ owner override-with-reason).
Topic management screen:
- Grid of topic bindings per connection: name, icon color chip, type badge (announce/control/audit), state (active/closed/archived), assigned accounts, linked VC, live policy summary chip.
- Actions: create (calls bot
createForumTopic, persists thread id), rename, close/reopen, bind/unbind accounts, set control link, edit policy (jumps to rules editor scoped to topic), archive (close + unpin).deleteis deliberately absent (console-only runbook with typed confirmation; destructive to history). - Header banner when forum mode is off on the target supergroup: runbook steps (toggle Topics, re-promote bot with
can_manage_topics) — we cannot detect or fix this from the Bot API; create attempts surface the Telegram error verbatim.
6. Security
- Token vault: bot tokens and any MTProto session are opaque vault credential handles (existing envelope-encryption vault, same boundary as stream keys —
credential-handles.tspattern). Tokens never appear in DTOs, logs, WS frames, argv, or chat. Gateway reads handles at boot and on rotation; every credential read is audited. Rotation runbook: BotFather/revoke→ re-vault → gateway reload → old token dead. - Identity: authorization keyed on immutable numeric
from.id→chat_identities(statusapprovedonly) → membership role → RBAC permission. Usernames are never trusted. Unregistered senders get/register, which creates apendingidentity an admin approves in console — no self-elevation. - Least privilege on-platform: bot promoted with only
can_post_messages,can_edit_messages,can_delete_messages,can_manage_topics— nevercan_promote_members. - Anti-abuse: layered rate limiting (§3);
update_iduniqueness kills replays; webhooksecret_tokenper binding rejects forged updates; per-user per-command-class buckets; flood governors already refuse (not queue) over-budget sends so a poll storm can't wedge the API; poll mirror edit-on-change + render-hash guard; one-open-poll-per-draft unique index stops poll spam gating the same draft;chat_command_auditmakes every attempt (including rejects) inspectable. - NEVER runs from chat without confirmation (and some never from chat at all):
- Confirm-gated:
/cut,/unlock,/override on,/stop— CONFIRM/ABORT + dry-run diff + 30 s TTL + requester-only callback + nonce idempotency. - Console-only, never from chat: credential/token management, role grants and identity approvals, topic delete, policy
dry_run→enforce flips, retention, workspace settings, and any multi-destination bulk publish that would exceed the broadcast lane (bulk always goes through the Temporal pipeline, never a chat command). - Structurally impossible from chat: reading vault contents, cross-workspace data (RLS + no chat surface for it), editing another user's draft.
- Confirm-gated:
- Privacy: anonymous polls store no voter linkage (ballot rows only for named mode); scrub() applies to all audited diffs (existing pattern); control chatter is confined to the locked
#controltopic, and the audit topic is append-only (bot has no delete there operationally). - Account posture (from scrape-feasibility): the multi-account feature targets our own legitimate accounts (bots/handles we own). No browser-automation posting, no scraped-account fan-out; any future MTProto admin sidecar (forum-mode enable, topic backfill) stays admin-ops-only, never bulk-posting — that is the documented boundary.
7. Phased build plan
| Phase | Slice | Deps | Acceptance criteria |
|---|---|---|---|
| P0 — spine | Migrations (§2 tables + RLS + indexes + retention classes), policy-service rule CRUD + compile-to-snapshot, gate_decisions writer, RBAC permissions (chatgov.*), audit events |
WP2 vault/RBAC, WP3 drafts | Migrations idempotent + lint-clean; RLS denial suite covers every new table (workspace B sees zero A rows); policy compile is pure + snapshot hash deterministic; policy.revised lands in audit chain |
| P1 — E1/E3 gates | Wire snapshot evaluation into review→approved (E1) and the Temporal dispatch worker (E3); blocked queue rows + reason codes; immediate/admin_only modes live |
P0 | immediate posts with no gate; admin_only blocks until one admin approve; E3 re-check blocks an approval revoked post-approve; partial fan-out observable (A fired, B blocked with codes); no behavior change for existing drafts |
| P2 — polls core (web) | poll-service + open/vote/close/evaluate; WS kind:"poll" frames; web chat poll card + vote flow; mixed arms, veto, timebox, min-dwell, anonymity; E2 pass→approve integration with exactly-once outbox |
P0, P1 | 2-of-admins poll passes → draft approved once under 5 concurrent closing triggers; veto fails immediately; timebox auto-reject fires once; ineligible/self votes stored-not-counted with codes; quorum_unreachable eager; draft edit after close blocks E3 (RESET_ON_CHANGE) |
| P3 — Telegram gateway | grammY module, webhook + secret per binding, identity registry + /register approval, command router with RBAC + per-class buckets, chat_command_audit, non-destructive commands (/tally, /pst, /take, /lock on, /topics) |
P0 | Replay of same update_id is a no-op; unregistered identity gets unauthorized + /register; wrong-permission command rejected with code + audited; /take//pst produce real DirectorReceipts via existing director(); /lock on on locked channel consistent with console |
| P4 — confirm UX + destructive | CONFIRM/ABORT conversations, nonce idempotency, dry-run diff echo, ephemeral replace (10.3), audit-topic mirror; /cut, /unlock, /override, /stop |
P3 | /cut without confirm never reaches the control plane; confirm panel expires at 30 s; second user's callback on my panel rejected; same nonce can't fire twice; every destructive action mirrored to audit topic with actor + before/after |
| P5 — poll mirror + topics | Inline-keyboard poll cards (callback_data ≤64B), edit-on-change throttled through governors, poll_mirrors; topic_bindings CRUD, bot create/close/rename topics, per-topic policy precedence, subchat channel keys in live-chat, accounts↔topics assignment |
P2, P3 | Vote via Telegram button updates web card ≤3 s and vice versa (same WS spine); card edits throttled ≥2 s and only on change (governor test); topic create returns persisted message_thread_id; topic policy overrides account policy (precedence test); forum-mode-off surfaces runbook banner |
| P6 — rules editor + topic screen + blocked queue | Console UI (§5): destination tree, arm builder, resolved preview, dry-run badge; topic management grid; blocked queue with gap-to-quorum + override-with-reason; mobile vote polish | P1, P2, P5 | Editor save shows exact effective preview matching compiled snapshot hash; dry-run badge blocks nothing but logs POLICY_DRY_RUN; blocked post card shows live gap + reason chips; override requires free-text reason and is audited |
| P7 — echo/auto-topic + hardening | Echo mode (pass→post into same chat/topic as 1-destination fan-out), auto-topic-per-series, triggerCompose crosspost hook (WP15), sentiment sendPoll, load test (vote storms), secret-scan pass, retention wiring, docs |
P5, P6 | Echo post lands in originating topic after pass with message_id stored; auto-topic created once per series and closed on archive; 1k votes/min sustained with flat WS queue + no unthrottled mirror edits; secret-scan clean on all gateway paths; docs updated |
Dependency order: P0 → P1 → P2 → {P3 → P4} ∥ P5(P2+P3) → P6 → P7. P3 can start immediately after P0 in parallel with P2 (independent seams: inbound commands vs. poll state machine).
Out of scope v1 (explicit): MTProto userbot sidecar (forum-mode enable, topic backfill — runbook doc only), weighted votes, OPA/Rego expression policies, Discord/Discourse poll connectors (schema is provider-neutral; adapters later), paid broadcast (allow_paid_broadcast).
8. Open items (flagged, decided by default)
of:eligiblerosters need a membership UI per topic — v1 shipsadmins+of:observed+ count-only; explicit rosters land with P6 if asked for.- Overlay auth for anonymous viewers voting in browser: v1 restricts overlay poll cards to display; voting happens in Telegram/web-app logged-in surface (avoids a new anon-auth surface).
- Multiple bots per workspace: supported (per-account tokens = blast-radius isolation + independent rate budgets); one poll may still span destinations on different bots — cards mirror per binding.