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>