T-HUB/docs/design/2026-10-09-chat-governance-design.md
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

38 KiB
Raw Permalink Blame History

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 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 <question> ; <opt1> ; <opt2>… 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 <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 → 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:<ws>:<channel> 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:<pollId>; 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.