Skip to main content

ADR-030 β€” Channel credential & endpoint isolation per (OSA Γ— Channel)

Status: Draft (pre-acceptance) Date: 2026-06-03 Issue: #1471 Parent epic: #1116 β€” Domain Model Alignment / Isolation Matrix Authors: @stoneton Related ADRs: ADR-007, ADR-020, ADR-0002 Phase 2 (Specialist email migration)

Context​

ADR-020 (2026-05-29) established a per-resource-class isolation matrix: Conversation, Message, and ExpertQueueItem must carry (org_id, specialist_id NOT NULL); Tools are Specialist-scoped; Expert visibility is dual-scoped via expert_access; KB defaults Org-wide with optional Specialist tag.

ADR-0002 Phase 2 (in flight under #1049) is migrating the Email channel to a per-OSA Gsuite user model β€” each org_specialist_assignments row gets its own emailAlias, gsuiteUserId, oauthRefreshTokenEncrypted. From the credential layer down, Email is already Org Γ— Specialist isolated.

ADR-020's matrix does not yet name a row for "channel credentials". In practice today:

  • integration_credentials is UNIQUE(org_id, integration_type). A single row per (Org, Slack) β€” one bot token, one signing_secret β€” serves every OSA in that Org.
  • WhatsApp and Telegram follow the same shape: one Twilio number / one TG bot per Org, shared across OSAs.
  • Email is the lone exception β€” credentials live on the OSA row, not in integration_credentials.

The result is a structural asymmetry: the strongest data-layer isolation work (Conversation/Message/Queue under ADR-020 row 1) is layered on top of the weakest credential-layer isolation. The carrier is shared even though the cargo is partitioned.

Problem​

P1 β€” Shared credentials defeat data isolation upstream of it​

OrgRlsInterceptor sets both app.current_org_id and app.current_specialist_id for Specialist-context requests. Service-layer queries on messages filter both. But the channel that delivered the message is authenticated by an Org-wide secret. A credential leak compromises every Specialist on that Org simultaneously. The weakest link is upstream of the data-layer work.

P2 β€” Inbound routing for non-Email channels is structurally poor​

Because the credential is per-Org, the inbound handler must do a second lookup to determine which OSA the event belongs to:

  • Slack β€” slack_channel_bindings.findOne({ org_id, external_channel_id }) for explicitly bound channels; otherwise fall through to is_primary=true OSA. DMs, ad-hoc channels, newly-created channels all collapse to the primary OSA.
  • WhatsApp β€” single Twilio number per Org; no channel concept; every inbound falls to primary OSA.
  • Telegram β€” single bot per Org; same fallback shape.

In production this means multi-OSA Orgs cannot meaningfully route WhatsApp or Telegram inbound to non-primary OSAs. The "Acme has Kai (KYC) + Mei (Finance) Specialists" promise breaks the moment a customer messages on WhatsApp.

P3 β€” Credential lifecycle cannot be operated per OSA​

Rotating a bot token, revoking access for a single OSA, or doing an incident-scoped credential reset all act at Org granularity. There is no path to suspend "Kai's Slack access" while leaving "Mei's Slack access" intact.

P4 β€” Display identity drifts from a single source of truth​

specialists.avatar_url, specialists.firstName, specialists.description are designed as the platform-wide identity of a Specialist. Today they are not propagated to provisioned channel surfaces:

  • A Slack bot installation captures the bot's display name/avatar at install time. Updating specialists.avatar_url does not push to the installed bot.
  • A WhatsApp business profile is set at number-provisioning time. Same problem.

Operators currently maintain N hand-synced copies of the same identity. For multi-Org Specialists, the drift surface grows with each new Org assignment.

P5 β€” Matrix gap​

ADR-020's table has rows for PII, KB, Tools, Expert visibility, LLM cost β€” but not for the credentials carrying the PII. Adding a new channel today requires a one-off decision per migration about credential scope. The decision is currently inconsistent (Email per-OSA; everything else per-Org).

Decision​

Adopt a three-layer channel architecture and add a 6th row to the ADR-020 isolation matrix.

Layer 1 β€” Identity (shared)​

The specialists table is the single source of truth for everything the customer perceives:

  • name, firstName, displayName
  • avatar_url
  • description, tagline
  • voice_profile, prompt_template_key

A change to a Specialist row must propagate to every provisioned channel surface that displays that Specialist. No per-OSA copy of identity fields except via the explicit display_overrides escape hatch (see Β§Layer 3).

Layer 2 β€” Instance (per OSA)​

org_specialist_assignments holds run parameters only:

  • confidence_threshold, monthly_rate_config
  • is_primary, lifecycle timestamps
  • tavus_replica_id

This row does not carry channel-specific credentials or display data. The Email-related Gsuite fields currently on OSA (emailAlias, gsuiteUserId, oauthRefreshTokenEncrypted, etc.) move to Layer 3 during the migration.

Layer 3 β€” Channel endpoint (per OSA Γ— Channel)​

A new table osa_channel_endpoints:

CREATE TABLE osa_channel_endpoints (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
osa_id UUID NOT NULL REFERENCES org_specialist_assignments(id) ON DELETE CASCADE,
channel_type VARCHAR(32) NOT NULL, -- 'slack' | 'email' | 'whatsapp' | 'telegram'
external_id VARCHAR(255) NOT NULL, -- bot_user_id | phone | @username | mailbox
credentials_secret_ref TEXT NOT NULL, -- AWS Secrets Manager ARN
provisioning_status VARCHAR(32) NOT NULL DEFAULT 'pending',
-- pending | provisioned | suspended | revoked
provisioned_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
display_overrides JSONB, -- NULL except where explicitly justified
shared_with_org BOOLEAN NOT NULL DEFAULT FALSE,
-- explicit opt-in to legacy shared-credential fallback
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX uniq_osa_channel_endpoint
ON osa_channel_endpoints (osa_id, channel_type)
WHERE revoked_at IS NULL;

CREATE UNIQUE INDEX uniq_channel_external_id
ON osa_channel_endpoints (channel_type, external_id)
WHERE revoked_at IS NULL;

CREATE INDEX idx_osa_channel_endpoints_osa ON osa_channel_endpoints (osa_id);
CREATE INDEX idx_osa_channel_endpoints_type_status
ON osa_channel_endpoints (channel_type, provisioning_status);

CREATE OR REPLACE FUNCTION touch_osa_channel_endpoints_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_osa_channel_endpoints_updated_at
BEFORE UPDATE ON osa_channel_endpoints
FOR EACH ROW EXECUTE FUNCTION touch_osa_channel_endpoints_updated_at();

Constraints:

  • One active endpoint per (osa_id, channel_type) β€” Kai-for-Acme has at most one active Slack endpoint at a time.
  • (channel_type, external_id) unique among active endpoints β€” the bot_user_id / phone / mailbox routes back to exactly one OSA.
  • Credentials never live in the row β€” only the Secrets Manager reference. AES-GCM at rest, IAM-scoped read in flight.

Outbound dispatch rule​

ChannelDispatcherService.dispatch(osaId, channel, payload) always:

  1. Loads endpoint = osa_channel_endpoints.findActive({ osa_id, channel_type }).
  2. Fails with a recoverable ChannelEndpointMissing error if endpoint is missing, revoked, or not active.
  3. Loads identity = specialists.findOne(osa.specialistId).
  4. Fails with a recoverable SpecialistIdentityMissing error if identity is missing or soft-deleted.
  5. Loads credentials = secretsManager.get(endpoint.credentials_secret_ref).
  6. Sends via the channel adapter with credentials/sender from Layer 3 and display data from Layer 1.

The display block (name, avatar, bio) must come from Layer 1 unless endpoint.display_overrides is non-null. Tests in api/test/channel-dispatcher-identity.spec.ts will enforce this invariant.

Inbound routing rule​

Webhooks reduce to a single-row lookup:

const endpoint = await osaChannelEndpoints.findOne({
channel_type,
external_id, // derived from webhook payload (channel id / to-phone / mailbox)
revoked_at: IsNull(),
});
if (!endpoint) throw new ChannelEndpointUnknown(); // -> HTTP 404
const { osa_id } = endpoint;
const osa = await osaRepo.findOne(osa_id);
if (!osa) throw new ChannelEndpointUnknown(); // -> HTTP 404
rls.setBoth({ org_id: osa.orgId, specialist_id: osa.specialistId });
// proceed to ADR-020 double-isolation

slack_channel_bindings degrades to an optional helper (e.g. "limit which channels Kai may auto-respond in") and is not on the routing critical path.

Identity broadcast​

A new event-driven sync layer keeps Layer 1 changes propagated to channel surfaces:

@OnEvent('specialist.updated')
async syncIdentityToChannels({ specialistId, changedFields }) {
if (!IDENTITY_FIELDS.some((f) => changedFields.includes(f))) return;
const endpoints = await osaChannelEndpoints.findAll({
osa: { specialist_id: specialistId },
provisioning_status: 'provisioned',
});
for (const ep of endpoints) {
await channelSyncQueue.add(`sync-${ep.channel_type}`, { endpointId: ep.id });
}
}

Per-channel sync processors translate identity changes into vendor API calls (users.profile.set for Slack, Gmail profile API for Email, Twilio business profile for WhatsApp, setMyName / setMyPhoto for Telegram).

Matrix extension (add row 6 to ADR-020)​

Resource classDefault scopeEnforcementExamples
Channel credentials & endpointsOrg Γ— Specialist (OSA Γ— Channel)UNIQUE(osa_id, channel_type) active; credentials in Secrets Manager keyed by endpoint; inbound routed by (channel_type, external_id)osa_channel_endpoints, ADR-0002 Phase 2 Gsuite fields (post-migration)

After acceptance, ADR-020 Β§Decision matrix is amended to include this row, and the entity docstring rule in ADR-020 Β§1 also applies to credential rows.

Per-channel application​

ChannelLayer-3 credentialLayer-1 display syncDetailed design
EmailOSA's own Gsuite user. Endpoint stores gsuite_user_id, oauth_refresh_token_encrypted_ref. Fields move from org_specialist_assignments to osa_channel_endpoints during migration.From: "Kai" <kai.acme@h.work> rendered from specialists.firstName + endpoint external_id. Gsuite profile photo synced from specialists.avatar_url.ADR-0002 Phase 2 (#1049)
SlackApp-per-Specialist (one App in h.work's developer account per Specialist) + install-per-OSA (one workspace install per OSA producing independent bot_user_id + access_token). Inbound routes by (api_app_id, team_id).Bot user identity (display name + avatar) set at install time via users.profile.set + users.setPhoto; synced from Specialist via event-driven job.030-channel-credential-isolation/slack.md
WhatsAppWABA-per-Specialist (platform-level identity container) + phone-number-per-OSA (deterministic routing key). Inbound resolves via To number; no second-step routing in default model.Per-number sender display "{Org.shortName} {Specialist.domain} by h.work"; WABA verified business name "{Specialist.firstName} by h.work". Profile photo / about synced from Specialist with provider rate-limit awareness.030-channel-credential-isolation/whatsapp.md
TelegramPersona-template-per-Specialist (L2 has no Telegram-native object) + bot-per-OSA created via MTProto-scripted BotFather. Each bot has independent token + globally-unique @username.Text fields (setMyName, setMyDescription, setMyCommands) sync via Bot API; avatar requires a dedicated TG MTProto worker because Bot API does not support setting a bot's own photo.030-channel-credential-isolation/telegram.md
PortalNo external credential.Renders directly from Specialist on every request.β€”

Migration plan (expand-contract)​

PhasePR scopeDone when
ExpandCreate osa_channel_endpoints; wire Secrets Manager helper for credentials_secret_ref.Table present, writes not yet enabled.
BackfillCopy current integration_credentials rows for slack/whatsapp/telegram into one endpoint per Org (attached to is_primary OSA, shared_with_org=true). Move ADR-0002 P2 Gsuite fields from OSA to endpoint rows for Email.Existing customer state visible in new table; zero downtime.
Dual-writeChannelDispatcher and inbound handlers write/read new table while keeping old read path as fallback.7-day production soak shows no fallback hits.
Per-OSA provision UXAM Setup Wizard adds explicit "Connect Slack for Kai", "Provision WhatsApp number for Mei" steps per non-primary OSA. New clients always provision per-OSA.New OSAs after this PR are guaranteed per-OSA endpoints.
Switch readInbound + outbound exclusively use osa_channel_endpoints. slack_channel_bindings removed from routing path.Old read path code deleted.
ContractDrop channel-type entries from integration_credentials; remove fallback path for shared_with_org=true that customers haven't explicitly opted into.Schema cleanup complete.

The CLAUDE.md Β§"Schema Migrations (zero-downtime under active-active)" expand-contract recipe is the canonical playbook for each step.

Risks and mitigations​

RiskSeverityMitigation
Per-OSA Slack install is operationally heavy (multiple h.work bots per workspace)MediumAM Setup Wizard automation; tie into #1159 (Slack App submission) so customers can self-install via App Directory.
Per-OSA WhatsApp number = per-OSA monthly Twilio costHigh (variable)Explicit consent gate at provisioning; shared_with_org=true fallback for cost-sensitive customers.
Dual-write window introduces transient inconsistencyMedium7-day soak with metric dashboards (Prom counters from channelFailures + new endpoint_resolution_miss counter); circuit-break to old path on miss.
display_overrides becomes a back-door for one-off branding requestsLowPR-review rule: every non-null display_overrides requires linked issue + business justification; periodic audit script flags rows.
Migration of existing Org-shared Slack credentials to "endpoint attached to primary OSA" looks like a renaming, but a multi-OSA client immediately needs an explicit second install for the non-primary OSAMediumBackfill phase emits a needs_per_osa_install flag visible in /ops/clients/:id so AMs can drive the install per affected customer.
WhatsApp portfolio-wide capacity ceiling β€” Meta caps unverified business portfolios at 2 phone numbers across all WABAs; raised to 20 only after Meta business verification or sustained 2,000-message-volume baseline; further raises require Meta support tickets. Messaging quota is also portfolio-wide since Oct 2025, meaning per-OSA growth on WhatsApp is bounded platform-wide, not per-Specialist. See whatsapp.md Β§Invariant I-4.HighTreat Meta business verification as an explicit pre-launch dependency on h.work's Kaito timeline; pursue verification in parallel with engineering work, not after; capacity-plan WhatsApp OSA growth against the 2/20/manual-ticket ladder; keep Slack + Email as the customer-facing channels that scale freely while WhatsApp capacity is being earned.

Alternatives considered​

A. Maintain status quo (per-Org credentials + binding-based routing)​

Rejected. The status quo is the source of P1–P4 above. ADR-020 is partially undone by it. WhatsApp/Telegram routing for non-primary OSAs cannot be fixed within this model without re-introducing per-OSA credentials.

B. Extend org_specialist_assignments with N more columns per channel​

Rejected. OSA already carries 20+ columns; adding slack_bot_token_ref, slack_external_id, whatsapp_phone_ref, whatsapp_external_id, telegram_token_ref, ... compounds the table without giving the row-level audit trail (provisioning_status, revoked_at) that a separate endpoint row provides. New channels would each require a schema migration to OSA.

C. Offload to Nango / external integration hub​

Rejected. Nango is well-suited to OAuth SaaS data integrations (Shopify, QuickBooks, Xero) and is already used for that purpose (ADR doc on Nango setup). It is not designed for webhook-signature verification at scale, multi-instance bot identity, or per-OSA business profile management. Forcing channels through Nango introduces cross-service consistency problems that CLAUDE.md Β§"Data Consistency Contract" explicitly forbids.

D. Application-layer identity overlay over a single shared bot​

Rejected as default; preserved as fallback. Slack's chat.postMessage allows username + icon_url per call, which can simulate per-OSA identity over a single Org bot. This solves P4 (display) but does not solve P1 (credential leak blast radius) or P3 (per-OSA lifecycle). It can be used as the shared_with_org=true opt-in for cost-sensitive customers but is not the platform default.

Consequences​

Positive​

  • Closes the ADR-020 matrix gap at the credential layer. The carrier and the cargo share the same isolation grain.
  • Inbound routing collapses from two-step to one-step lookup; WhatsApp and Telegram gain real OSA-level routing for the first time.
  • Credential leak blast radius shrinks from Org β†’ single (OSA Γ— Channel) endpoint.
  • Specialist identity becomes single-write, multi-channel-broadcast β€” operators stop maintaining N copies.
  • Per-OSA suspend/revoke becomes a row-level operation.

Negative​

  • Operationally heavier onboarding for multi-OSA customers (multiple Slack installs / Twilio number provisions).
  • WhatsApp Twilio cost scales with OSA count for customers that need per-OSA isolation.
  • Migration touches every channel adapter and the AM Setup Wizard; nontrivial PR fan-out (sequenced as expand-contract).

Neutral​

  • expert_access (ADR-007) is orthogonal and unchanged. Expert visibility still flows through expert_access regardless of which endpoint a message arrived through.
  • LLM cost rollup (ADR-020 row 5) continues to pivot on osa_id on messages; endpoint metadata is not on the billing path.

Open follow-up issues (post-acceptance)​

  1. Implementation of osa_channel_endpoints schema + Secrets Manager wiring (Expand phase).
  2. Migration of ADR-0002 P2 Gsuite fields from OSA into endpoint rows.
  3. Slack per-OSA install flow β€” combined with #1158 (inbound module refactor) and #1159 (App Directory submission).
  4. WhatsApp per-OSA number provisioning UX β€” depends on Twilio business verification flow.
  5. Telegram per-OSA bot provisioning.
  6. Identity sync job (channelSyncQueue) with per-channel processors.
  7. Deprecation of slack_channel_bindings as a routing primitive.

References​

  • ADR-007 β€” Expert access scope (dual-scope precedent for "Org-wide vs Specialist-scoped")
  • ADR-020 β€” Isolation classification matrix (this ADR amends row 6)
  • ADR-0002 Phase 2 β€” Specialist email migration (first instance of the target model)
  • Epic #1116 β€” Domain Model Alignment
  • Issue #1471 β€” this ADR
  • Issue #1158 β€” Slack inbound module refactor
  • Issue #1159 β€” Official Slack App submission
  • Issue #830 β€” AWS Secrets Manager migration
  • Issue #809 β€” ADR doc batch