feat(channels): native channel-instance dimension — multi-bot substrate#2733
Merged
Conversation
Adds the channel-instance dimension to the schema: an `instance` column (NOT NULL, default instance = channel_type) on messaging_groups, relaxing UNIQUE(channel_type, platform_id) to the triple so N adapter instances of one platform can each own a row per chat. SQLite can't relax a table-level UNIQUE in place, and DROP TABLE fails FK integrity on live DBs with child rows (the failure that forced migration 011 to abandon its rebuild) — so the migration runner grows an opt-in `disableForeignKeys` flag: foreign_keys=OFF around the transaction (the pragma is a no-op inside one), PRAGMA foreign_key_check inside it so a violating recreate rolls back atomically. Query semantics (deliberately asymmetric, both documented): - getMessagingGroupWithAgentCount (router fast path): exact-on-instance, no fallback — an unknown named instance returns null so the router auto-creates a per-instance group instead of hijacking a sibling's row. Default param (= channelType) keeps existing callers identical. - getMessagingGroupByPlatform (outbound/cold-DM/setup): unset instance resolves default-instance-first with a deterministic ORDER BY; set instance is exact-only. Existing rows are backfilled instance = channel_type, so single-instance installs see zero behavior change and need no operator action. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… fallback ChannelAdapter and InboundEvent gain an optional `instance` field — the host-side routing identity for N adapters of one platform. channelType stays the semantic platform key (user ids, formatting, container config). Registry changes: - activeAdapters keys by `adapter.instance ?? adapter.channelType`, so the default instance keeps today's channelType key byte-identically. A duplicate instance key warns loudly and overwrites (today's boot semantics, made visible). - getChannelAdapter(key) resolves the exact instance key first, then falls back to the first-registered adapter of that channel type — channelType- only callers (cold DMs, user-id prefix resolution, approval delivery) still resolve deterministically when every instance of a platform is named. - initChannelAdapters staggers same-channelType setups by 10s so two gateway bots of one platform don't identify simultaneously from one IP. Inert when no two registrations share a channelType. No adapter sets `instance` today, so every existing install boots identically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…utes
ChatSdkBridgeConfig gains `instance`. The bridge keeps channelType =
adapter.name (semantic platform identity is untouched) and threads the
instance into three places:
- Registry identity: bridge.name / bridge.instance follow config.instance.
- Chat SDK state: SqliteStateAdapter takes an optional namespace and
prefixes every key at a single choke point (k()). All bridges share the
chat_sdk_* tables and two same-platform instances see identical
thread/message ids — without the namespace, the SDK's
dedupe:${adapter.name}:${message.id} key makes the second bot silently
drop every message the first processed, locks serialize across bots, and
subscriptions leak engagement. The namespace applies ONLY when instance
is set AND differs from adapter.name: the default instance stays on the
legacy UNPREFIXED keyspace byte-identically, so live installs' existing
subscriptions/kv/locks/lists rows are never orphaned. enqueue does not
prefix (appendToList does) — layout is ns:queue:<tid>; acquireLock
returns the raw threadId and release/extend re-apply k() at their SQL
sites.
- Webhook route: registerWebhookAdapter(chat, adapterName, routingPath =
adapterName) splits the URL segment from the chat.webhooks handler key,
so each same-platform instance gets its own URL (and signing secret).
Signature adopted verbatim from PR #2617 (credit @davekim917's #1804
prototype); the handler body needed zero change — dispatch already read
entry.adapterName, not the route key.
Instance names are validated URL-safe (no '/', '?', ':' or whitespace) at
bridge construction: the route regex is [^/?]+ and ':' is the namespace
delimiter. The Chat instance's inner adapters map stays keyed adapter.name
(the SDK resolves adapters via channelId.split(':')[0] and serializes by
adapter.name) — instance identity lives entirely outside the Chat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… typing Inbound: src/index.ts onInbound stamps `instance: adapter.instance ?? adapter.channelType` — the single host-side stamping seam; adapters stay instance-blind and onInboundEvent (CLI) passes events through unchanged. The router resolves the thread-policy adapter and the messaging group by the receiving instance (exact-only — an unknown named instance auto-creates its own group, persisting the instance, instead of hijacking a sibling's row). Outbound: ChannelDeliveryAdapter.deliver/setTyping grow a trailing `instance` param (host-internal interface only — messages_out, destinations and session_routing schemas are untouched; containers never see instance). deliverMessage resolves the messaging group ORIGIN-SESSION-FIRST, so a named instance's session replies through its own adapter even when a sibling default row shares the same (channel_type, platform_id); dispatch goes through getChannelAdapter(instance ?? channelType). Typing: TypingTarget stores the instance and all three tick sites (immediate, 4s interval, re-trigger) forward it, so the indicator fires through the bot that owns the chat. Also updates a raw-SQL fixture in groups.test.ts for the NOT NULL instance column. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- CLAUDE.md entity model: instance on messaging_groups. - db-central.md: updated messaging_groups DDL (instance NOT NULL, triple UNIQUE, denied_at), instance semantics (default = channel_type via migration 016 backfill; inbound exact-on-instance, outbound default-first), and the user_dms per-platform (not per-instance) cold-DM note. - architecture.md: same DDL update in the schema appendix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n-safe skill snippets Review-round fixes on the instance dimension: - delivery/typing resolve adapters by exact registry key, never the channelType fallback — a named instance with an offline adapter gets offline handling, not a cross-identity send through a sibling bot; the fallback scan (channelType-only callers) now warns when it resolves through a differently-keyed instance - migration runner only fails on FK violations a migration introduced: pre-existing latent orphans (FK-OFF CLI surgery) are logged and carried, not turned into a boot crash-loop - typing re-trigger updates the full address (channelType, platformId, threadId, instance) together — no torn entries on agent-shared sessions spanning instances - bridge rejects empty/whitespace instance names (URL-route and state-namespace safety) - add-github / add-linear SKILL.md wiring inserts include the NOT NULL instance column - drop the 10s same-platform boot stagger: operational policy, not substrate — reintroducible skill-side for gateway-mode installs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 tasks
gavrielc
added a commit
that referenced
this pull request
Jun 11, 2026
…'s instance migration Both PRs branched from the same base and picked the next free number. The runner dedupes by name so runtime behavior is unaffected either way; the renumber avoids a barrel symbol conflict for whichever merges second. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 tasks
gavrielc
added a commit
that referenced
this pull request
Jun 11, 2026
…files The instance route-split suite (from #2733) keeps src/webhook-server.test.ts; this branch's raw-route suite moves to src/webhook-server-raw.test.ts — incompatible lifecycle setups (fixed port + afterEach vs random port + afterAll) make a single merged file wrong. webhook-server.ts auto-merge verified: raw routes take dispatch priority, stop clears both maps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jun 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type of Change
.claude/skills/<name>/, no source changes)(None of the boxes fit: this is core substrate — the "watch for hotspots → add a proper hook" maintainer commitment from docs/skills-model.md. It adds a dimension that turns multi-bot skills into clean adds; the bots themselves stay skills.)
Description
What
A native channel-instance dimension: N adapter instances per platform (e.g. three Slack apps in one workspace), keyed by an
instancename that defaults tochannelType.messaging_groups.instance— NOT NULL, backfilled tochannel_type,UNIQUE(channel_type, platform_id, instance)(migration 016: FK-OFF table recreate; the runner gainsdisableForeignKeysand fails only on FK violations a migration introduces — pre-existing latent orphans are logged and carried, never a boot crash-loop)instance ?? channelType; delivery/typing dispatch by exact key (a named instance with an offline adapter gets offline handling — never a cross-identity send through a sibling bot); the channelType fallback survives only for channelType-only callers (cold DMs, approval delivery) and warns when it crosses instancesChatSdkBridgeConfig.instance— one field driving the registry key, the webhook route (/webhook/<instance>), and the Chat SDK state namespace. State keys are namespaced only wheninstance !== adapter.name, so existing installs'chat_sdk_*rows stay byte-identicalregisterWebhookAdapter(chat, adapterName, routingPath?)— adopted verbatim from feat(chat-sdk-bridge): add channelType override + webhook routingPath #2617 (credit: @mmahmed); routes by path, dispatches by adapter nameinstancethreaded through router (lookup + auto-create), delivery (origin-session-first resolution), and typingchannelTypestays the semantic platform key everywhere — user ids (<channel>:<handle>), formatting, container config. Containers stay instance-blind: zero session-DB and zerocontainer/changes.Why
Same-channel multi-bot is the one shape the current keying can't express: the registry,
UNIQUE(channel_type, platform_id), webhook routes, and Chat SDK state all assume one adapter per platform. A real fork (PR Factory: worker/supervisor/tester Slack trio) needed reach-ins across 12 core files to add this dimension — the definition of a hotspot under docs/skills-model.md's maintainer commitments. With this substrate, that becomes a channel skill that just registers two more adapters. Refs #1804, refs #2653 (both are instance-shaped demand). Complementary to #2617, which targets multi-workspace (where changing the effective channelType is correct); this PR covers multi-bot within one workspace/channel, where channelType must stay stable or user identity fractures.How it was tested
build,typecheckclean;lintbyte-identical to the dc34ceb baseline (114/13, all pre-existing)git diff dc34ceb -- container/is empty). Note:poll-loop — /upload-traceflaked during verification — it makes a live Hugging Face call and races a 5s timeout; reproduces on pristine dc34ceb code, unrelated to this PR (worth a separate issue)chat-sdk-bridge.tsthat should be synced after this landsNotes for review
.claude/skills/add-githubandadd-linearwiring INSERTs gain theinstancecolumn (NOT NULL has no SQL default).🤖 Generated with Claude Code