Date: 2026-04-23 Status: Accepted Owner: @pilartomas
ADR-016 established that messenger integrations live inside the API Server and follow a pluggable "channel" abstraction, treating all channels uniformly. In practice, messengers split along a platform-vs-instance axis that has real consequences for secrets, identity, and authorization:
- Platform channel (Slack, ADR-018). One app serves the whole install. The operator configures it once via Helm values (
slackBotToken,slackAppToken). Per-instance configuration is routing metadata — which Slack channel ID this instance listens to. Identity linking ties Slack users to Keycloak subs at the workspace level (ADR-018). - Per-instance channel (Telegram). Each instance owns its own bot, created by the instance operator via
@BotFather. The platform never learns the bot token at install time; it arrives through the UI when an instance operator wires up their channel.
Adding Telegram made it clear that treating per-instance as a distinct category — with its own patterns for secrets, authorization, and lifecycle — is clearer than stretching Slack's platform model over it. This ADR codifies the per-instance pattern so future channels (WhatsApp Business, Discord, SMS providers where the tenant brings credentials) can follow it mechanically.
Bot tokens live in namespaced k8s Secrets:
- Name:
humr-channel-<type>-<instanceId>(e.g.humr-channel-telegram-acme-bot) - Labels:
humr.ai/type=channel-secret,humr.ai/instance=<id>,humr.ai/channel-type=<type> - Data key:
botToken
Consequences of this shape:
- Postgres keeps only the fact that a channel is enabled (a row in
channelswith emptyconfigfor Telegram). No bot secret at rest in the database. - Tokens never round-trip to the UI — the input is write-only with a "Change token" affordance for updates.
- The
TelegramConnectedevent carries onlyinstanceId. The channel manager reads the token from the secret store at worker-start time. Tokens do not traverse the in-memory event bus. - Lifecycle is trivial: connect = create, disconnect = delete. Instance deletion cascades via label selector in the existing k8s cleanup saga.
- RBAC already grants the api-server Secret verbs in the agent namespace (unchanged since ADR-006); no chart changes needed.
Rejected:
- Plaintext in Postgres. Leaks via DB dump, backup, or incidental logging.
- App-level encryption of
channels.config. Still needs key management without gaining the benefits of a native primitive. - One Secret per instance carrying all channel tokens. Forces read-modify-write on every connect/disconnect plus bookkeeping for "delete the Secret once the last channel is gone." Per-(instance, type) gives clean lifecycle at negligible object-count cost.
- External secret manager (Vault, ESM). Out of scope; the
ChannelSecretStoreinterface is small enough that a Vault-backed implementation can slot in later.
Slack's model is: the bot is a member of a channel, anyone in the channel can mention it, and a per-instance allowed-users list gates who is actually served. That works because bot identity and workspace membership are platform-level facts.
Telegram has no workspace. The bot joins arbitrary DMs and groups. So we shift the authorization boundary from the user to the conversation:
- A thread (DM or group) is inactive until someone runs
/login, which launches a Keycloak OAuth flow. On successful callback, the conversation is marked authorized intelegram_authorized_threads. /logoutrevokes authorization.- In groups, only admins may
/loginor/logout(verified viagetChatMember). - Unauthorized threads: the bot prompts for
/loginin DMs; stays silent in groups (to avoid spamming every chat the bot happens to be in).
"Authorize the conversation, not the user." It matches the Telegram primitive and avoids maintaining a workspace-wide identity mapping for a per-instance bot.
Each authorized thread holds a single ACP session, persisted in the sessions table keyed by instance + thread. This keeps conversation context coherent across turns without replaying history on every message. See ADR-025 for the analogous Slack pattern; the idea is identical, the authorization model is not.
The public TelegramChannel type exposes only { type }. The connectTelegram mutation takes the token as input; all read paths (instances.list, instances.get, Instance.channels) omit it. Stored vs. view types are separated: StoredTelegramChannel (internal, carries the token) vs. TelegramChannel (public, metadata only).
| Aspect | Slack (platform, ADR-018) | Telegram (per-instance) |
|---|---|---|
| Who installs the bot | Humr operator, once | Instance owner, via @BotFather |
| Token provenance | Helm values → env var | UI input → k8s Secret |
| Identity model | Slack user ↔ Keycloak sub (workspace-wide) | Thread authorization (no user mapping) |
| Access control | Per-instance allowed-users list | /login per conversation |
| Scope | One bot serves all instances | One bot per instance |
- Adding a new per-instance channel is mechanical: a new worker, routing wiring, and reuse of
ChannelSecretStore+ the thread-authorization pattern. - Operators running purely with Slack are unaffected.
- The channels table in Postgres stores only metadata. This closes the "prototype gap" called out in ADR-016 (channel config in ConfigMaps).
- Per-tenant key management falls on k8s Secret handling — no new crypto code, no new key rotation procedure beyond what Humr already inherits.
- Tokens are not printable anywhere in the platform UI once saved; operators changing a token must paste a new one.
- ADR-005 (credential gateway): does not apply — messenger tokens are platform-consumed (by the API Server), not agent-outbound.
- ADR-016 (messenger integration): this ADR refines the per-instance case. The "channel config lives in a ConfigMap" note in ADR-016 is superseded — channels are now in Postgres and their secrets in k8s Secrets.
- ADR-018 (Slack integration): the platform-channel counterpart.
- ADR-025 (persistent ACP session per Slack thread): same "session per thread" idea with Slack's authorization model instead.