Files
multica/server/migrations/124_channel_generalization.up.sql
Bohan Jiang 5e824a94ae chore(channel): remove the one-time MULTICA_LARK_HUB_DISABLED cutover switch (#4527)
The lark_*->channel_* cutover (MUL-3515) is deployed to prod, and the
MULTICA_LARK_HUB_DISABLED park-switch was a one-time scaffold for that
rollout — the end state intentionally does not use it (prod never set the
env). Remove the env-gated branch from cmd/server/main.go so the channel
supervisor always starts when built; its existing nil-guard and shutdown
join are unchanged. Trim migration 124's now-obsolete switch runbook to a
short historical note (comment-only; 124 is already applied, so this does
not re-run).

Refs MUL-3515

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-24 18:18:06 +08:00

307 lines
14 KiB
SQL

-- Generalize the Feishu/Lark-specific integration tables into
-- platform-agnostic channel_* tables (MUL-3515, parent MUL-3506). Each
-- lark_* table gains a `channel_type` discriminator and moves its
-- platform-specific identifiers/config into a JSONB `config` column; the
-- cross-platform columns stay flat. Existing Feishu rows are backfilled
-- with channel_type='feishu'.
--
-- Two hard rules from the design:
--
-- * NO foreign keys and NO cascades (MUL-3515 §4). The lark_* tables
-- leaned on composite FKs to enforce "a binding's workspace matches
-- its installation" and "a binding dies when workspace membership is
-- revoked / a chat_session is deleted". Those integrity rules now
-- live in the application layer (the cutover PR adds the membership
-- check + cleanup). The columns are kept so the app can still join,
-- but the database enforces nothing.
--
-- * The lark_* tables are NOT dropped here — that happens in a later
-- migration once the Go cutover has landed, so this migration can
-- ship green on its own. This migration only ADDS channel_* and
-- copies the data forward.
--
-- * ROLLOUT. This backfill is a one-time copy and the Lark hub is an
-- in-process goroutine, so a cutover has TWO independent invariants:
-- (a) channel_* must exist BEFORE any new (channel_*) build serves the
-- HTTP paths that touch it — installation list/install/revoke,
-- chat-session delete, member revoke — or those paths 500.
-- (b) the OLD (lark_*) hub and the NEW (channel_*) hub must never run at
-- once: each would claim the same Feishu bot's WS lease on its own
-- table and open a duplicate connection, double-processing inbound
-- events (duplicate messages / /issue / runs). The two table sets
-- never cross-deduplicate.
--
-- SELF-HOST (Docker Compose / Helm) satisfies both automatically and needs
-- NO flags or manual steps. The backend entrypoint runs `migrate up`
-- before the server starts, so channel_* exists before the new build
-- serves (a); the deployment is single-replica `Recreate`, so the old pod
-- (and its hub) fully stops before the new pod starts (b). A normal
-- version upgrade is a clean cutover. Only a self-host re-tuned to
-- multi-replica RollingUpdate needs the prd procedure below.
--
-- PRD (rolling multica-api, maxUnavailable:0) overlapped old and new pods.
-- A one-time MULTICA_LARK_HUB_DISABLED park-switch existed during the
-- cutover to hold a hub dormant while the API stayed up, so only one hub
-- was ever live (invariant b). That cutover is complete and the switch has
-- since been removed (MUL-3515); this note is kept as history. Rollback to
-- a pre-cutover build is not lossless once the new hub has written Feishu
-- state into channel_*.
--
-- app_secret_encrypted is BYTEA; it is carried into the JSONB config as a
-- base64 string. PostgreSQL's encode(...,'base64') MIME-wraps the output
-- with a newline every 76 chars, and a secretbox-sealed app secret (~72
-- bytes) exceeds that, so we strip the newlines: Go's encoding/json decodes
-- a base64 string into a []byte field with base64.StdEncoding, which rejects
-- embedded newlines. Stripping keeps the bytea -> JSON -> []byte round-trip
-- symmetric (the Go writer emits unwrapped base64 too) and the ciphertext is
-- never stored in plaintext.
-- =====================
-- channel_installation
-- =====================
CREATE TABLE channel_installation (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
agent_id UUID NOT NULL,
channel_type TEXT NOT NULL,
-- Platform-specific identifiers/config. For feishu:
-- app_id, app_secret_encrypted (base64), tenant_key, bot_open_id,
-- bot_union_id, region.
config JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'revoked')),
ws_lease_token TEXT,
ws_lease_expires_at TIMESTAMPTZ,
installer_user_id UUID NOT NULL,
installed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- One installation per (agent, channel_type): an agent may connect more
-- than one IM at once (feishu + slack + ...), but only one of each kind.
-- The old lark_installation had UNIQUE(workspace_id, agent_id) because
-- feishu was the only channel; with a channel_type discriminator the
-- natural generalization adds it to the key. In the current feishu-only
-- world this is behaviorally identical (one row per agent). If the
-- product later wants "one agent, at most one IM regardless of type",
-- that is an application-layer rule (MUL-3515 §4), not a DB constraint.
UNIQUE (workspace_id, agent_id, channel_type)
);
CREATE INDEX idx_channel_installation_workspace ON channel_installation(workspace_id);
CREATE INDEX idx_channel_installation_agent ON channel_installation(agent_id);
CREATE INDEX idx_channel_installation_lease ON channel_installation(ws_lease_expires_at)
WHERE status = 'active';
-- Routing key. Inbound events carry only the platform app identifier
-- (Feishu app_id); the dispatcher routes on (channel_type, app_id). The
-- functional unique index replaces the old global UNIQUE(app_id) and is
-- scoped per channel_type. Rows without an app_id (a future channel that
-- routes differently) store JSON null here, and Postgres allows many
-- NULLs in a unique index, so they do not collide.
CREATE UNIQUE INDEX idx_channel_installation_type_appid
ON channel_installation(channel_type, (config ->> 'app_id'));
INSERT INTO channel_installation (
id, workspace_id, agent_id, channel_type, config, status,
ws_lease_token, ws_lease_expires_at, installer_user_id,
installed_at, created_at, updated_at
)
SELECT
id, workspace_id, agent_id, 'feishu',
jsonb_strip_nulls(jsonb_build_object(
'app_id', app_id,
'app_secret_encrypted', replace(encode(app_secret_encrypted, 'base64'), E'\n', ''),
'tenant_key', tenant_key,
'bot_open_id', bot_open_id,
'bot_union_id', bot_union_id,
'region', region
)),
status, ws_lease_token, ws_lease_expires_at, installer_user_id,
installed_at, created_at, updated_at
FROM lark_installation;
-- =====================
-- channel_user_binding
-- =====================
-- channel_user_id is the platform-native, per-installation user id
-- (Feishu open_id). union_id and any other secondary identity goes in
-- config. The member-FK that used to make a row's existence proof of
-- workspace membership is gone; the cutover PR validates membership in
-- the identity check and prunes bindings on member removal.
CREATE TABLE channel_user_binding (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL,
multica_user_id UUID NOT NULL,
installation_id UUID NOT NULL,
channel_type TEXT NOT NULL,
channel_user_id TEXT NOT NULL,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
bound_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (installation_id, channel_user_id)
);
CREATE INDEX idx_channel_user_binding_user
ON channel_user_binding(multica_user_id, workspace_id);
CREATE INDEX idx_channel_user_binding_workspace_user
ON channel_user_binding(workspace_id, channel_user_id);
INSERT INTO channel_user_binding (
id, workspace_id, multica_user_id, installation_id,
channel_type, channel_user_id, config, bound_at
)
SELECT
id, workspace_id, multica_user_id, installation_id,
'feishu', lark_open_id,
jsonb_strip_nulls(jsonb_build_object('union_id', union_id)),
bound_at
FROM lark_user_binding;
-- =====================
-- channel_chat_session_binding
-- =====================
CREATE TABLE channel_chat_session_binding (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_session_id UUID NOT NULL,
installation_id UUID NOT NULL,
channel_type TEXT NOT NULL,
channel_chat_id TEXT NOT NULL,
chat_type TEXT NOT NULL
CHECK (chat_type IN ('p2p', 'group')),
-- Most-recent inbound trigger, so the decoupled outbound patcher can
-- thread its reply back into the originating topic. Nullable; a NULL
-- thread id keeps the chat-level send path.
last_message_id TEXT,
last_thread_id TEXT,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (installation_id, channel_chat_id),
UNIQUE (chat_session_id)
);
CREATE INDEX idx_channel_chat_session_binding_session
ON channel_chat_session_binding(chat_session_id);
INSERT INTO channel_chat_session_binding (
id, chat_session_id, installation_id, channel_type,
channel_chat_id, chat_type, last_message_id, last_thread_id, created_at
)
SELECT
id, chat_session_id, installation_id, 'feishu',
lark_chat_id, lark_chat_type, last_lark_message_id, last_lark_thread_id, created_at
FROM lark_chat_session_binding;
-- =====================
-- channel_inbound_message_dedup
-- =====================
-- Two-phase idempotency with owner fencing, unchanged in shape from
-- lark_inbound_message_dedup (keyed per installation + message). Transient
-- 24h cache; copied forward for completeness.
CREATE TABLE channel_inbound_message_dedup (
installation_id UUID NOT NULL,
message_id TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
claim_token UUID NOT NULL DEFAULT gen_random_uuid(),
PRIMARY KEY (installation_id, message_id)
);
CREATE INDEX idx_channel_inbound_dedup_received
ON channel_inbound_message_dedup(received_at);
INSERT INTO channel_inbound_message_dedup (
installation_id, message_id, received_at, processed_at, claim_token
)
SELECT installation_id, message_id, received_at, processed_at, claim_token
FROM lark_inbound_message_dedup;
-- =====================
-- channel_inbound_audit
-- =====================
-- Non-content drop audit. installation_id is nullable (the old ON DELETE
-- SET NULL is now just a nullable column the app may leave NULL).
CREATE TABLE channel_inbound_audit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
installation_id UUID,
channel_type TEXT NOT NULL,
channel_chat_id TEXT,
event_type TEXT NOT NULL,
channel_event_id TEXT,
channel_message_id TEXT,
drop_reason TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_channel_inbound_audit_installation
ON channel_inbound_audit(installation_id, received_at DESC);
CREATE INDEX idx_channel_inbound_audit_reason
ON channel_inbound_audit(drop_reason, received_at DESC);
INSERT INTO channel_inbound_audit (
id, installation_id, channel_type, channel_chat_id, event_type,
channel_event_id, channel_message_id, drop_reason, received_at
)
SELECT
id, installation_id, 'feishu', lark_chat_id, event_type,
lark_event_id, lark_message_id, drop_reason, received_at
FROM lark_inbound_audit;
-- =====================
-- channel_outbound_card_message
-- =====================
CREATE TABLE channel_outbound_card_message (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_session_id UUID NOT NULL,
task_id UUID,
channel_type TEXT NOT NULL,
channel_chat_id TEXT NOT NULL,
channel_card_message_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'streaming', 'final', 'error')),
last_patched_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_channel_outbound_card_task
ON channel_outbound_card_message(task_id)
WHERE task_id IS NOT NULL;
CREATE INDEX idx_channel_outbound_card_session
ON channel_outbound_card_message(chat_session_id, created_at DESC);
INSERT INTO channel_outbound_card_message (
id, chat_session_id, task_id, channel_type, channel_chat_id,
channel_card_message_id, status, last_patched_at, created_at
)
SELECT
id, chat_session_id, task_id, 'feishu', lark_chat_id,
lark_card_message_id, status, last_patched_at, created_at
FROM lark_outbound_card_message;
-- =====================
-- channel_binding_token
-- =====================
CREATE TABLE channel_binding_token (
token_hash TEXT PRIMARY KEY,
workspace_id UUID NOT NULL,
installation_id UUID NOT NULL,
channel_type TEXT NOT NULL,
channel_user_id TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Keep the product TTL cap in lockstep with channel.BindingTokenTTL
-- (15 minutes), same as the old lark_binding_token CHECK.
CONSTRAINT channel_binding_token_ttl_cap
CHECK (expires_at <= created_at + INTERVAL '15 minutes')
);
CREATE INDEX idx_channel_binding_token_installation
ON channel_binding_token(installation_id, expires_at);
INSERT INTO channel_binding_token (
token_hash, workspace_id, installation_id, channel_type,
channel_user_id, expires_at, consumed_at, created_at
)
SELECT
token_hash, workspace_id, installation_id, 'feishu',
lark_open_id, expires_at, consumed_at, created_at
FROM lark_binding_token;