# 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|cut|pst-select|lock|override|desired`); authorization reuses workspace RBAC permissions (`director.take`, `director.lock_program`, …) resolved from immutable Telegram numeric `from.id` → `chat_identities` → membership role. Chat is UX; the control plane stays the enforcement point. | | 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): ```jsonc { "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 since `opens_at` (roster derived from `live_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 own `x`. - **State machine**: `open → passed | failed | vetoed | quorum_unreachable | cancelled`. `quorum_unreachable` fires eagerly when remaining eligible voters < needed (grey-the-card UI). Closed is terminal; a re-vote is a **new poll with `parent_poll_id`**. - **Close events**: (1) quorum ∧ minDwell → early close; (2) `closes_at` timebox expiry → `onExpire` action; (3) veto → immediate. Evaluation is event-driven (every ballot re-evaluates); the timebox is the only timer (Temporal `pollTimebox` workflow + lazy re-eval on read). - **Idempotent close**: state flip and downstream trigger live in one transaction guarded by `poll_outbox` exactly-once claim; double-close cannot double-publish. - **Native Telegram polls**: `/sentiment ; ; …` sends `sendPoll` (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: 1. **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_id` when topic-bound) → `message_id` stored for edit/withdraw. Fail/veto → `reject` with poll reference. 2. **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_bindings`** registry: `(workspace, connection, chat_id, message_thread_id)` → role `announce | 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`, needs `can_manage_topics`), rename (`editForumTopic`), close/reopen (`closeForumTopic`/`reopenForumTopic`), plus `unpinAllForumTopicMessages` on archive. **`deleteForumTopic` is 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 ride `message_thread_id` of 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_version` hash persisted on the binding; evaluation reads only snapshots. Every rule supports `dryRun` (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 ` | `{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 ` | `{type:"override"}` | `director.override_auto` | **yes** | | `/start` `/stop` | desired RUNNING/STOPPED | `director.take` | stop: **yes** | | `/topics new\|close\|list` | topic registry ops | `chatgov.manage` | no | | `/post [+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:` (≤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). ```sql -- 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:' | 'chat_poll:' 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` → existing `LiveChatHubService.ingest` (same dedupe, seq, ring buffer); a topic binding supplies its own `channel_id` so each subchat sequences and fans out independently on the existing `chat::` Redis topic. Outbound, `poll-mirror` and acks go through `SendGovernors`-wrapped transports — a new `ChatTransport` impl for telegram whose `send`/`edit` consume the same token buckets. Governance frames ride the same fanout as `kind:"chat"`: new `kind:"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 `LiveChatCapability` in `packages/core/src/chat-contract.ts` with `poll: "none" | "vote" | "sentiment"` and `topics: boolean`; `capability-probe.ts` verifies a callback_query round-trip before advertising `vote` (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 as `Idempotency-Key` → `chat_command_audit` row → 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.manage` approves). Unregistered identity ⇒ everything except `/help`, `/register`, `/tally` is `unauthorized`. Unlinked control binding ⇒ commands rejected with `NO_CHANNEL_BOUND`. - **Rate limiting, layered**: grammY throttler (outbound API shape) → `SendGovernors` token 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-mirror` edits only when `render_hash` changes. --- ## 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:** 1. Draft created (console or `/post`) with N destinations → each destination's effective snapshot resolved → `gate_decisions` rows created `pending`. 2. Poll opened (auto on submit when an arm requires chat signal, or manually): rule compiled from the destination's most-gating arm; `polls.rule` snapshot + `policy_version` pinned. 3. **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_unreachable` checked eagerly. Card edits flow through mirror throttle. 4. **Close** (quorum ∧ minDwell | timebox `onExpire` | veto): claim `outbox_state` (exactly-once), set final state, then in the *same transaction*: pass ⇒ `publishingService` transition `in_review → approved` with **system actor** `system:chat-poll-gate:`; veto/fail ⇒ `→ rejected` with reason. The poll id doubles as the idempotency key — replayed closes are no-ops. `approved_hash` pinning stays intact: a post-close draft edit re-validates at E3 and is blocked (`RESET_ON_CHANGE` / hash mismatch). 5. **E3 pre-dispatch** (Temporal `scheduledPublish` worker, per destination): re-read snapshot + approvals + poll state + `approved_hash`; evaluate; write the `gate_decisions` outcome (`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. 6. **Audit trail**: `poll.opened/voted/closed` + `policy.evaluated` + `release.fired/blocked` all append via `audit_append` (tamper-evident chain, actor = system identity with poll ref); every decision record embeds `policy_version`. `admin_only` mode = same path with a 1-arm poll or an E1-only console approval. `immediate` mode = 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 → publishing` phase 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 ribbon `PASS ✓ / 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`), `join` toggle, veto switch, min-dwell, timebox duration + `onExpire` select, 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_version` hash is displayed. Every save writes `policy.revised` to the audit chain; edits require `chatgov.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). `delete` is 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.ts` pattern). 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` (status `approved` only) → membership role → RBAC permission. **Usernames are never trusted.** Unregistered senders get `/register`, which creates a `pending` identity 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` — never `can_promote_members`. - **Anti-abuse**: layered rate limiting (§3); `update_id` uniqueness kills replays; webhook `secret_token` per 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_audit` makes 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. - **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 `#control` topic, 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) 1. **`of:eligible` rosters** need a membership UI per topic — v1 ships `admins` + `of:observed` + count-only; explicit rosters land with P6 if asked for. 2. **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). 3. **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.