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

385 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 <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` | **yes** |
| `/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).
```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:<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.