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_credentialsisUNIQUE(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 tois_primary=trueOSA. 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_urldoes 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,displayNameavatar_urldescription,taglinevoice_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_configis_primary, lifecycle timestampstavus_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-Acmehas 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:
- Loads
endpoint = osa_channel_endpoints.findActive({ osa_id, channel_type }). - Fails with a recoverable
ChannelEndpointMissingerror ifendpointis missing, revoked, or not active. - Loads
identity = specialists.findOne(osa.specialistId). - Fails with a recoverable
SpecialistIdentityMissingerror ifidentityis missing or soft-deleted. - Loads
credentials = secretsManager.get(endpoint.credentials_secret_ref). - 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 class | Default scope | Enforcement | Examples |
|---|---|---|---|
| Channel credentials & endpoints | Org Γ 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β
| Channel | Layer-3 credential | Layer-1 display sync | Detailed design |
|---|---|---|---|
OSA'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) | |
| Slack | App-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 |
| WABA-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 | |
| Telegram | Persona-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 |
| Portal | No external credential. | Renders directly from Specialist on every request. | β |
Migration plan (expand-contract)β
| Phase | PR scope | Done when |
|---|---|---|
| Expand | Create osa_channel_endpoints; wire Secrets Manager helper for credentials_secret_ref. | Table present, writes not yet enabled. |
| Backfill | Copy 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-write | ChannelDispatcher 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 UX | AM 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 read | Inbound + outbound exclusively use osa_channel_endpoints. slack_channel_bindings removed from routing path. | Old read path code deleted. |
| Contract | Drop 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β
| Risk | Severity | Mitigation |
|---|---|---|
Per-OSA Slack install is operationally heavy (multiple h.work bots per workspace) | Medium | AM 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 cost | High (variable) | Explicit consent gate at provisioning; shared_with_org=true fallback for cost-sensitive customers. |
| Dual-write window introduces transient inconsistency | Medium | 7-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 requests | Low | PR-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 OSA | Medium | Backfill 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. | High | Treat 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 throughexpert_accessregardless of which endpoint a message arrived through.- LLM cost rollup (ADR-020 row 5) continues to pivot on
osa_idonmessages; endpoint metadata is not on the billing path.
Open follow-up issues (post-acceptance)β
- Implementation of
osa_channel_endpointsschema + Secrets Manager wiring (Expand phase). - Migration of ADR-0002 P2 Gsuite fields from OSA into endpoint rows.
- Slack per-OSA install flow β combined with #1158 (inbound module refactor) and #1159 (App Directory submission).
- WhatsApp per-OSA number provisioning UX β depends on Twilio business verification flow.
- Telegram per-OSA bot provisioning.
- Identity sync job (
channelSyncQueue) with per-channel processors. - Deprecation of
slack_channel_bindingsas 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