Slack Per-OSA Install with App-per-Specialist Identity Model
Status: Draft (subdocument of ADR-030) Date: 2026-06-03 Issue: #1471 Parent ADRs: ADR-007, ADR-020 (this design extends row 6 of the matrix for Slack) Related work: #1158 (Slack inbound module refactor), #1159 (Slack Marketplace submission, formerly App Directory) Implementation tracking: dedicated P2 follow-up issue (to be opened post-acceptance)
1. Product invariantsβ
Three rules govern Slack at h.work. They are h.work product decisions, not Slack platform requirements.
Invariant I-1 β Specialist owns one primary Slack Appβ
Every Specialist that engages with Slack owns exactly one primary Slack App registered under h.work's Slack developer account. The App is the Specialist's Slack identity container. It owns:
- Slack Marketplace listing (if Marketplace distribution is enabled)
- App manifest (OAuth scopes, event subscriptions, slash commands, redirect URIs)
client_id/client_secret(App-level OAuth credentials)signing_secret(App-level webhook signature secret)- Bot user template (default display name, default avatar)
- Distribution mode (
undistributed/unlisted_distributed/marketplace_listed/ Enterprise Grid org-wide) - Submission / verification lifecycle (if applicable)
- Operational ownership boundary
A Specialist MAY also own zero or more secondary Apps for limited reasons (staging / testing / region / provider migration); see Β§11.
Invariant I-2 β Every OSA gets a dedicated workspace installβ
By default, each (Org Γ Specialist) assignment β an OSA β receives one dedicated install of that Specialist's App into the Org's Slack workspace. Each install produces an independent:
bot_user_id(per workspace)access_token(per workspace, the actual credential)- Workspace-scoped bot profile (display name, photo)
The (api_app_id, team_id) tuple is the deterministic routing key for that OSA.
Invariant I-3 β Inbound (api_app_id, team_id) resolves exactly one OSAβ
Every Slack inbound event resolves to exactly one OSA via single-row lookup on (api_app_id, team_id). From the resolved OSA endpoint the platform derives:
org_idspecialist_idchannel_endpoint_idconversationscope (per ADR-020 row 1)- RLS GUC values (
app.current_org_id,app.current_specialist_id)
There is no second-step routing in the default model. The legacy slack_channel_bindings table degrades from a routing primitive to optional channel-scope metadata (see Β§10.3).
2. Display identity model (App-level vs install-level)β
Slack surfaces three identity surfaces, and the design must be explicit about each.
2.1 Slack Marketplace listing name (one per Specialist, optional)β
If the Specialist's App is submitted to the Slack Marketplace (formerly "App Directory"):
Marketplace listing name = "{Specialist.firstName} by h.work"
example: "Kai by h.work"
This is the listing name customers see when searching the Slack Marketplace. Setting / changing it triggers Slack Marketplace review (multi-week SLA; eligibility requirements may include minimum active installs).
For unlisted distributed Apps (recommended default for h.work β see Β§13), this listing does not appear in the public Marketplace. The App still has a name visible to workspace admins during OAuth install consent, but it is not discoverable by Slack search.
2.2 Bot user display name per workspace (one per OSA install)β
When the App is installed into a workspace, Slack creates a bot user inside that workspace. The bot user has its own display name that customers see when they DM the bot or see it post in channels.
Bot user display_name = "{Specialist.firstName}" (default)
OR
"{Org.shortName} {Specialist.domain}" (per-OSA override)
example: "Kai" or "Acme KYC"
By default this is just the Specialist's first name, keeping branding consistent across Orgs. An Org may opt for an Org-scoped name override (e.g., "Acme KYC") at OSA provisioning time, stored on the endpoint row.
2.3 Bot avatar per workspace (one per OSA install)β
Bot avatar = specialists.avatar_url
Set at install time via users.profile.set (or App manifest default), synced when Specialist avatar changes. Subject to Slack rate limits.
2.4 Profile fieldsβ
| Field | Source | Per-App or per-install |
|---|---|---|
| Marketplace listing name | "{Specialist.firstName} by h.work" | per-App (Specialist-level) |
| App icon (Marketplace listing) | specialists.avatar_url | per-App |
| Bot user display name | specialists.firstName (default) or per-OSA override | per-install (OSA-level), best-effort |
| Bot user avatar | specialists.avatar_url | per-install, synced (best-effort) |
| Bot user status / "what I do" | specialists.description (truncated) | per-install, best-effort |
| OAuth scopes | Defined in App manifest | per-App |
| Event subscriptions | Defined in App manifest | per-App |
| Slash commands | Defined in App manifest | per-App |
| Redirect URI | Defined in App manifest | per-App |
signing_secret | Generated at App creation | per-App |
client_id / client_secret | Generated at App creation | per-App |
access_token (bot token) | OAuth exchange at install | per-install (OSA-level) |
bot_user_id | Returned at install | per-install |
Identity rule: the Specialist row is the source for firstName, avatar_url, description. Identity-related changes to a Specialist row trigger a best-effort sync to every provisioned Slack endpoint of that Specialist via Slack-supported profile APIs (rate-limited; see Β§6). If Slack denies bot-profile mutation for a given app/workspace/token type (e.g., not_allowed_token_type, missing_scope, or per-workspace profile restriction), the endpoint remains provisioned and profile_sync_state records unsupported or failed; the App manifest bot template remains the fallback identity.
Security note: signing_secret is App-level. A leak of one App's signing_secret allows forged webhooks for all installs of that App (all OSAs of that Specialist on Slack). Mitigation: signing_secret rotation is an App-level event that must propagate to verification code; document as an incident-response runbook.
3. Architecture overviewβ
h.work Slack developer account
β
βββ App: Kai-by-hwork (Specialist = Kai, Domain = KYC)
β api_app_id = "A0KAIHWORK"
β listing_name = "Kai by h.work"
β distribution = unlisted_distributed | marketplace_listed
β role = primary
β βββ Install team_id = T0ACME β OSA(Kai, Acme) bot_user_id = U0KAIACME, bot display = "Kai"
β βββ Install team_id = T0BIGCORP β OSA(Kai, BigCorp) bot_user_id = U0KAIBIG, bot display = "Kai"
β βββ Install team_id = T0DATA β OSA(Kai, DataCorp) bot_user_id = U0KAIDATA, bot display = "Kai"
β
βββ App: Mei-by-hwork (Specialist = Mei, Domain = Finance)
β api_app_id = "A0MEIHWORK"
β βββ Install team_id = T0ACME β OSA(Mei, Acme) bot_user_id = U0MEIACME, bot display = "Mei"
β βββ Install team_id = T0DATA β OSA(Mei, DataCorp)
β βββ Install team_id = T0SHOP β OSA(Mei, ShopCo)
β
βββ App: Lin-by-hwork (Specialist = Lin, Domain = E-commerce)
api_app_id = "A0LINHWORK"
βββ Install team_id = T0BIGCORP β OSA(Lin, BigCorp)
βββ Install team_id = T0SHOP β OSA(Lin, ShopCo)
Cardinality:
| Object | Count formula | Bound |
|---|---|---|
| Slack App (primary) | M = number of Specialists with Slack | one per Specialist |
| Slack App (secondary) | 0..K per Specialist | exception-driven |
| Workspace install | sum(OSAs per Specialist with Slack enabled) | one per OSA |
| Endpoint row | identical to install count | one per OSA |
Note: Acme's Slack workspace may contain multiple h.work bots simultaneously (one per Specialist Acme has hired). That is expected and product-acceptable.
4. Inbound routingβ
Slack POSTs all events to URLs configured on the App. We use a path-encoded api_app_id to disambiguate which Specialist's App without trusting the body:
Slack workspace T0BIGCORP, user @bob mentions @Kai in #kyc-queue
β
βΌ
Slack β POST https://api.h.work/v1/channels/slack/webhook/{api_app_id}
path: /webhook/A0KAIHWORK
body: { team_id: "T0BIGCORP", api_app_id: "A0KAIHWORK", event: {...} }
header X-Slack-Signature: <hmac>
header X-Slack-Request-Timestamp: <ts>
β
βΌ
SlackController
1. Extract api_app_id from URL path
2. Load persona by api_app_id β fetch signing_secret from Secrets Manager
3. Verify HMAC signature using signing_secret (App-level)
Reject with 401 if signature invalid; reject with 401 if timestamp drift > 5 min (replay protection)
4. If body.type == 'url_verification': return body.challenge (200) and stop.
(url_verification arrives during App-level request URL configuration;
it carries a valid signature but has no associated install, so
endpoint lookup is skipped β but signature check is NOT skipped.)
5. InboundDedupService.check('slack', event.event_id || event.event_ts)
6. Enqueue normalized event to BullMQ (slack-inbound queue) with persona_id + team_id
7. Return HTTP 200 immediately (Slack expects ACK in <3s)
β
βΌ
(BullMQ worker, async)
SlackInboundProcessor
1. Resolve endpoint:
SELECT *
FROM osa_channel_endpoints
WHERE channel_type = 'slack'
AND persona_id = :persona_id
AND external_id = :team_id
AND provisioning_status = 'provisioned'
AND revoked_at IS NULL;
2. If not found:
- If event is a lifecycle event (app_uninstalled / tokens_revoked):
log orphan event + return (no error). Avoids Slack retry storm.
- Otherwise: log slack.inbound.endpoint_not_found and drop.
(Inbound 200 already returned; Slack will not retry.)
3. Derive (org_id, specialist_id) from endpoint.osa_id
4. rls.setBoth({ org_id, specialist_id })
5. Customer.findOrCreate({ org_id, external_id: 'slack:' + slack_user_id, channel: 'slack' })
6. Conversation.findOrCreateByChannel(...)
7. Message saved β agent dispatch β expert queue
Invariant check (enforced in inbound handler unit tests):
expect(endpoint.specialist_id).toBe(conversation.specialist_id);
expect(endpoint.org_id).toBe(conversation.org_id);
expect(endpoint.persona.api_app_id).toBe(eventBody.api_app_id);
HTTP response policy (to avoid Slack retry storms while preserving security):
| Condition | Response |
|---|---|
| Signature invalid / timestamp drift | 401 |
Signature valid + url_verification | 200 with challenge |
| Signature valid + endpoint resolves | 200 (ACK only); async worker handles business logic |
| Signature valid + unknown endpoint, normal message | 200 + structured log slack.inbound.endpoint_not_found (don't 404 β Slack would retry) |
Signature valid + unknown endpoint, lifecycle event (app_uninstalled, tokens_revoked) | 200 + log orphan event (handle idempotently if endpoint exists with non-active state) |
Persona not found by api_app_id | 401 (treat as forged β we never accept events for personas we don't own) |
Special events:
url_verificationβ handled after signature verification, before endpoint routing (see flow above). It does not require endpoint lookup because no install exists yet, but it must not bypass signature verification.app_uninstalledβ triggers endpoint suspension (see Β§8.2). Token is no longer usable post-event.tokens_revokedβ triggers endpoint revocation. Payload may includebotand/orusertoken IDs; onlybotis processed by this design.usertoken revocations are logged as no-op until user-auth scope is added in a future ADR.
5. Outbound dispatchβ
async dispatch(osaId: string, payload: SlackPayload): Promise<void> {
const endpoint = await endpoints.findActive({ osaId, channel_type: 'slack' });
if (!endpoint) throw new ChannelNotProvisionedError('slack', osaId);
const persona = await personas.findOne(endpoint.persona_id);
const identity = await specialists.findOne(osa.specialist_id);
const creds = await secretsManager.get(endpoint.credentials_secret_ref);
// creds = { bot_access_token: 'xoxb-...' }
await withCircuit('slack', () =>
withRetry(() =>
slackClient(creds.bot_access_token).chat.postMessage({
channel: payload.channel, // channel_id (DM or channel)
thread_ts: payload.thread_ts, // optional thread anchor
text: payload.text,
blocks: payload.blocks,
// username + icon_url override NOT used by default β bot user identity
// is set at install time via users.profile.set so the bot appears
// natively in the workspace, not as a webhook impersonation.
}),
{ maxAttempts: 3, baseDelayMs: 200, label: 'dispatch.slack' },
),
);
}
Customer's Slack client renders the sender as the workspace's bot user with the bot's display name + avatar (set at install per Β§2). The bot user is a real Slack user in that workspace, not a per-message webhook impersonation.
6. Identity sync jobβ
@OnEvent('specialist.updated')
async onSpecialistChanged({ specialistId, changedFields }: SpecialistChange) {
if (!intersects(changedFields, IDENTITY_FIELDS)) return;
const endpoints = await endpoints.findAll({
channel_type: 'slack',
osa: { specialist_id: specialistId },
provisioning_status: 'provisioned',
revoked_at: IsNull(),
});
for (const ep of endpoints) {
await channelSyncQueue.add('sync-slack', {
endpointId: ep.id,
changedFields,
}, {
jobId: `slack-sync-${ep.id}`, // dedup
attempts: 5,
backoff: { type: 'exponential', delay: 60_000 },
});
}
}
Bot profile sync is best-effort. h.work attempts to update the App's own bot user profile in each workspace using Slack-supported APIs where the App's token type and granted scopes permit. If Slack denies the mutation, the endpoint remains provisioned and profile_sync_state records the result.
The sync processor:
- Resolves endpoint β persona β identity
- Attempts
users.profile.seton the App's own bot user with new display name / status - Attempts
users.setPhotofor avatar - Attempts App-level Marketplace listing icon update via App Manifest API if Specialist avatar changes (only for Marketplace-listed Apps; manifest API may require re-submission)
- Records per-field result in
osa_channel_endpoints.profile_sync_stateJSONB:{
"display_name": { "status": "synced", "last_synced_at": "...", "last_error": null },
"avatar": { "status": "unsupported", "last_synced_at": null, "last_error": "not_allowed_token_type" },
"status_text": { "status": "failed", "last_synced_at": "...", "last_error": "missing_scope:users.profile:write" }
} - Respects Slack rate limits (per-method tiers per workspace); throttle config lives in a provider-config table, not hard-coded.
Fields with constraints:
- Marketplace listing name β
not synced automatically; requires App-level Slack Marketplace review (only applies if App is Marketplace-listed) - App Manifest scopes β
not synced automatically; scope additions require re-install consent from each Org admin (Slack OAuth scopes are additive across grants β see Β§14.4) - Bot user display name β sync attempted via
users.profile.set; may be denied by Slack for some token types or workspace policies (per-install profile restrictions) - Bot user avatar β sync attempted via
users.setPhoto; subject to image size constraints (Slack documents 512Γ512 min, 1024Γ1024 max) - Bot user "status" / description β sync attempted via
users.profile.set
Failure handling: a sync failure does not block conversation flow. The endpoint continues to serve inbound/outbound traffic with the previously-synced (or manifest-default) identity. AM ops dashboard surfaces endpoints with profile_sync_state showing unsupported or persistent failed so manual intervention can be triggered (e.g., scope re-grant via reinstall).
7. Data modelβ
Reuses the same shared tables as the WhatsApp design. Slack-specific columns called out.
-- =========================================================================
-- L2: Specialist's Slack identity container (one primary App per Specialist)
-- Shared schema with WhatsApp (see whatsapp.md Β§7)
-- =========================================================================
-- Slack-specific column usage on specialist_channel_personas:
-- channel_type = 'slack'
-- provider = 'slack'
-- external_app_id = api_app_id (e.g. 'A0KAIHWORK')
-- verified_business_name = Marketplace listing name when listed, NULL otherwise
-- verification_status = 'pending' | 'in_review' | 'marketplace_approved'
-- | 'unlisted' (distributed but not Marketplace-listed)
-- | 'undistributed' (single-workspace dev/test only)
-- metadata.distribution_mode = 'undistributed' | 'unlisted_distributed'
-- | 'marketplace_listed' | 'enterprise_grid_org_wide'
-- metadata.manifest_version = current published manifest version
-- metadata.manifest_ref = R2 path to manifest JSON
-- metadata.oauth_client_id = App-level client_id
-- metadata.signing_secret_ref = Secrets Manager ARN for signing_secret
-- metadata.oauth_client_secret_ref = Secrets Manager ARN for client_secret
-- metadata.token_rotation_enabled = boolean β whether manifest enables OAuth token rotation
-- =========================================================================
-- L3: Per-OSA Slack workspace install
-- Shared schema with WhatsApp (see whatsapp.md Β§7)
-- =========================================================================
-- Slack-specific column usage on osa_channel_endpoints:
-- channel_type = 'slack'
-- provider = 'slack'
-- external_id = team_id (e.g. 'T0BIGCORP') -- workspace ID, routing key within persona namespace
-- credentials_secret_ref = Secrets Manager ARN, value = { bot_access_token: 'xoxb-...',
-- bot_refresh_token: '...' (nullable),
-- expires_at: ... (nullable) }
-- token rotation is per-App opt-in (manifest); refresh fields may be null
-- sender_display_name = bot user display name in that workspace (defaults to Specialist.firstName)
-- metadata.team_id = team_id (mirrored for query convenience)
-- metadata.team_name = workspace name at install time
-- metadata.bot_user_id = U-style bot user id in the workspace
-- metadata.app_id = api_app_id (mirrored from persona for fast lookup)
-- metadata.installed_by_slack_user_id = Slack user ID of installing admin
-- metadata.installed_scopes = scope strings granted at install (additive across reinstalls)
-- -- Enterprise Grid preparation (forward-compat; not active in default model):
-- metadata.enterprise_id = E-style enterprise ID, NULL for standalone workspaces
-- metadata.is_enterprise_install = boolean, true for org-wide installs
-- metadata.authorizing_team_id = team_id of the installer's home workspace (Grid only)
Inbound reverse-lookup unique indexβ
Slack uniqueness is scoped within a persona β the same workspace MAY host multiple Specialist Apps simultaneously (Acme installs both Kai App and Mei App). The global uniq_endpoint_external_id (channel_type, external_id) from the parent schema does not apply to Slack because two different Apps can legitimately have endpoints with the same team_id.
Slack uses a per-persona composite unique index instead:
-- Per-persona uniqueness: one workspace can install a given App at most once
CREATE UNIQUE INDEX uniq_slack_endpoint_persona_team
ON osa_channel_endpoints (persona_id, external_id)
WHERE channel_type = 'slack' AND revoked_at IS NULL;
The parent schema's uniq_endpoint_external_id must therefore be redefined as channel-conditional:
-- Apply global (channel_type, external_id) uniqueness ONLY to channels where
-- external_id is platform-globally unique (whatsapp phone numbers, email mailboxes).
-- Slack is excluded because team_id is unique only within a persona's namespace.
DROP INDEX IF EXISTS uniq_endpoint_external_id;
CREATE UNIQUE INDEX uniq_endpoint_external_id
ON osa_channel_endpoints (channel_type, external_id)
WHERE revoked_at IS NULL
AND channel_type IN ('whatsapp', 'telegram', 'email');
Schema notesβ
external_id = team_idfor Slack endpoints; uniqueness is enforced withinpersona_id, not globally.- A workspace install graph:
(persona_id, team_id)β exactly one OSA endpoint. Acme + Kai-App is one row; Acme + Mei-App is another row. signing_secretlives in Secrets Manager keyed by persona, not endpoint β App-level.bot_access_tokenlives in Secrets Manager keyed by endpoint β install-level.metadata.team_idis intentionally mirrored fromexternal_idfor SQL convenience in queries that join across channels (avoidsWHERE external_id = ...with channel-specific semantics).- Enterprise Grid fields are present in metadata for forward-compat. The default install model is workspace-scoped; org-wide install support is a follow-up (see Β§10).
8. State machinesβ
8.1 Persona (Slack App) lifecycleβ
created (undistributed; dev workspace only)
β
βΌ
manifest_drafted
β
βΌ
distribution_activated (unlisted_distributed; installable to customer workspaces via OAuth URL)
β
β (only if opting into Slack Marketplace)
βΌ
marketplace_submitted
β
βΌ
in_review ββββΊ βββ marketplace_approved βββ
β β
βΌ βΌ
provisioned rejected (terminal until edit + resubmit)
(Marketplace-listed
AND installable)
β
βΌ
suspended ββββΊ provisioned
β
βΌ
deprecated (no new installs allowed; existing installs unaffected until revoked)
For unlisted_distributed Apps (the default) the flow is created β manifest_drafted β distribution_activated β provisioned β no Slack review path. provisioned simply means "App is in a state where customer workspaces can install it." Whether that state was reached via Marketplace approval or via unlisted distribution activation is tracked in metadata.distribution_mode.
8.2 Endpoint (workspace install) lifecycleβ
Event subscription is an App-level config that lives on the persona/manifest. It is not a per-install state. Profile sync is best-effort and does not gate provisioning.
pending
β
βΌ
oauth_redirect_sent (AM clicked "Install to Acme"; OAuth URL given to Org admin)
β
βΌ
oauth_callback_received (Slack POSTed code; we exchanged for token via oauth.v2.access)
β
βΌ
token_persisted (bot_access_token in Secrets Manager; bot_user_id, team_id captured)
β
βΌ
provisioned ββββΊ suspended (app_uninstalled event suspends; successful reinstall restores)
β
βΌ
revoked (tokens_revoked event OR explicit revocation;
row preserved for audit with revoked_at set)
Profile sync (display name + avatar) runs after the endpoint reaches provisioned and updates profile_sync_state independently; sync failure never demotes the endpoint.
Failure & recovery:
- OAuth scope-grant mismatch (Org admin denied a required scope at consent) β endpoint stays in
oauth_callback_receivedwith explicit error; AM re-issues install URL. tokens_revokedfrom Slack β endpoint moves torevoked; AM must re-install to restore. Outbound dispatch is blocked from any non-provisionedendpoint.app_uninstalledfrom Slack β endpoint moves tosuspended; the workspace's token is invalidated by Slack, sosuspendedmeans "relationship restorable via reinstall," not "credentials still usable." Re-install reactivates (existing conversations remain associated by(persona_id, team_id)).profile_sync_statefailures do not transitionprovisioning_status; endpoint staysprovisioned, sync job retries via DLQ. Persistentunsupportedis surfaced in ops dashboard, not auto-resolved.
Token rotation note: if the App opts into OAuth token rotation (manifest token_rotation_enabled: true), bot_refresh_token + expires_at are populated and a refresh worker maintains tokens. If rotation is not enabled, the token is long-lived and the refresh fields stay NULL β both modes are supported.
9. Provisioning flow (default per-OSA mode)β
9.1 One-time per Specialist (App creation)β
Trigger: SuperAdmin creates Specialist + opts in to Slack
1. Create specialist_channel_personas row (provisioning_status = 'pending')
2. POST to Slack Apps Manifest API (or manual one-time creation in api.slack.com)
manifest:
display_information:
name: "Kai by h.work"
description: <from Specialist.description>
background_color: "#hexcolor"
features:
bot_user:
display_name: "Kai"
always_online: true
oauth_config:
scopes:
bot: [chat:write, channels:read, im:history, im:write, app_mentions:read, ...]
redirect_urls:
- https://api.h.work/v1/channels/slack/oauth/callback
settings:
event_subscriptions:
request_url: https://api.h.work/v1/channels/slack/webhook/{api_app_id}
bot_events: [app_mention, message.im, app_uninstalled, tokens_revoked]
interactivity:
is_enabled: true
request_url: https://api.h.work/v1/channels/slack/interactivity/{api_app_id}
3. Capture api_app_id, signing_secret, client_id, client_secret
4. Store signing_secret + client_secret in Secrets Manager
5. **Activate distribution** (required before any external workspace can install):
- For `unlisted_distributed` (default): enable distribution in App settings;
persona transitions to provisioned. App is installable via OAuth URL but
not visible in Slack Marketplace.
- For `marketplace_listed` (opt-in): submit App for Slack Marketplace review;
persona enters `in_review` β `marketplace_approved` β `provisioned`.
- For `undistributed`: dev/test only β App can only be installed in the
workspace where it was created. Not usable for customer onboarding.
Operational note: a Specialist's persona must reach provisioned and have distribution activated before any OSA endpoint can be installed into a customer workspace. AM Setup Wizard reflects this.
9.2 Per-OSA (workspace install)β
Trigger: AM enables Slack for OSA (e.g., Kai-for-Acme)
1. Pre-check: persona.provisioning_status = 'provisioned' AND distribution activated
2. Create osa_channel_endpoints row (provisioning_status = 'pending')
3. AM generates an OAuth install URL:
https://slack.com/oauth/v2/authorize
?client_id={persona.client_id}
&scope={comma-separated bot scopes from manifest}
&redirect_uri=https://api.h.work/v1/channels/slack/oauth/callback
&state={signed JWT-style token containing osa_id, persona_id, expected_org_id, nonce, iat, exp}
4. AM sends URL to Acme Slack admin (or AM walks them through install in a screen-share)
5. Acme admin authorizes the install in their Slack workspace
β status = 'oauth_redirect_sent'
6. Slack POSTs to redirect_uri with auth code + state
β state token verified:
- signature valid
- exp not passed (5-min TTL)
- nonce not previously consumed (single-use, Redis SET NX)
β exchange code at https://slack.com/api/oauth.v2.access
β response includes: { access_token: 'xoxb-...', bot_user_id, team: { id, name },
scope, refresh_token? (if rotation enabled), expires_in? }
β post-exchange validation:
- returned api_app_id == persona.api_app_id (Slack returns it; verify match)
- team_id is not already bound to another active endpoint under the same persona
- granted scope contains all required scopes from manifest
- if mismatch: log + transition to 'failed'; do not persist token
β status = 'oauth_callback_received' β 'token_persisted' (Secrets Manager write)
7. Final transition: 'provisioned' + emit channel.endpoint.provisioned event
8. Async (non-blocking): kick off profile-sync job to attempt:
POST users.profile.set { profile: { display_name: bot_display_name } }
POST users.setPhoto (upload Specialist.avatar_url)
β records result in profile_sync_state; does NOT gate provisioning
All vendor API calls go through withCircuit + withRetry. OAuth state token is short-lived (5 min), single-use (nonce dedup in Redis), and carries expected_org_id to prevent install binding to wrong OSA.
OAuth scope additivity note: Slack OAuth scopes accumulate across repeated installs of the same App into the same workspace. Adding scopes requires a fresh OAuth consent. Reducing scopes is not possible via re-install β it requires explicit auth.revoke followed by reinstall with the smaller scope set.
9.3 Revocationβ
Trigger: OSA termination, Org admin uninstalls App, customer churn
Path A β Slack-initiated uninstall (app_uninstalled event):
1. Receive app_uninstalled event (signature verified)
2. Endpoint moves to 'suspended' (not revoked β reinstall can restore)
3. Bot token is invalidated by Slack; secret stays in Secrets Manager for audit
4. Outbound dispatch is blocked for any non-`provisioned` endpoint
Path B β Explicit revocation (AM action or OSA termination):
1. POST auth.revoke with bot_access_token
2. revoked_at = now()
3. Mark Secrets Manager secret for deletion (30-day soft delete)
4. Endpoint row preserved for audit; lifecycle events for this team_id+app_id
received post-revocation are logged + 200-acked (avoid Slack retry storms)
Path C β Slack-initiated token revocation (tokens_revoked event):
1. Receive tokens_revoked event (signature verified)
2. Inspect payload: process only `bot` token IDs in this design;
`user` token IDs are logged as no-op (user-auth scope is future)
3. Mark endpoint revoked (if any bot token in this workspace was revoked)
4. Notify AM to re-install if customer relationship continues
10. Out of scope (deferred to other ADRs)β
The following are intentionally not part of this design:
| Feature | Where | Why deferred |
|---|---|---|
| Org-shared front-door bot (one bot per Org with internal topic-based routing to multiple Specialists) | ADR-028 (future) | Co-located with WhatsApp front-door deferral. Requires topic classifier. Conflicts with ADR-020 row 1. |
| Topic classifier subsystem | ADR-028 | β |
| Slack Connect cross-workspace channels | Not relevant | h.work is not the inter-org link in customer Slack Connect setups. |
| Slack Enterprise Grid org-wide installs | Future extension | Single-workspace install model first; Enterprise Grid via enterprise_id field on endpoint metadata is a follow-up. |
| Per-Org bot display name customization at install | Optional opt-in | The sender_display_name column supports it; not promoted as default. |
slack_channel_bindings table | Degrade to optional metadata | Historically used for routing; in the new model routing is by (api_app_id, team_id). The table can remain to express "this Kai-for-Acme is allowed to listen in channel X" but is no longer load-bearing. Removal planned in a follow-up cleanup after dual-write soak. |
The current ADR commits only to the default per-OSA dedicated install model. Front-door, Enterprise Grid, and per-Org bot name override are acknowledged as future options, not promised.
10.3 Migration of slack_channel_bindingsβ
Today: slack_channel_bindings = (org_id, external_channel_id) β specialist_id
Used to route DMs / channel events when only one Org-level bot existed.
New model: routing decided at (api_app_id, team_id) β endpoint.
A workspace install IS the OSA, so all events from that team_id
belong to that OSA regardless of which channel within the workspace.
Migration:
1. After per-OSA install model lands, slack_channel_bindings.specialist_id
becomes redundant for routing.
2. Keep the table as optional "scope hint" (Kai can listen only in #kyc-queue;
Mei can listen only in #finance) β useful for noise reduction in shared
workspaces.
3. Drop the routing dependency on this table in #1158 (Slack inbound refactor).
4. Schedule full table removal once no consumers rely on its specialist_id
column (separate contract-phase PR).
11. Secondary App exceptionβ
A Specialist MAY have additional Apps beyond the primary. Allowed reasons:
| Reason | shard_key value | Use case |
|---|---|---|
| Staging | env:staging | Separate App for staging environment so dev installs don't pollute production App's install list |
| Testing | env:test | Dedicated App for e2e tests against real Slack workspaces |
| Migration | migration:<reason> | Transitioning between manifest versions that require re-install (e.g., new scopes) |
| Region | region:<ISO> | Rare; only relevant if Slack adds regional App namespaces in future |
Rules:
- Each Specialist has exactly one primary App with
role='primary'andshard_key IS NULL. Enforced by partial unique index. - Secondary Apps do not change Specialist identity ownership; they typically share the same listing name in Slack Marketplace if visible.
- Endpoint creation may pick a secondary App explicitly during provisioning; the choice is recorded on the endpoint.
Unlike WhatsApp's WABA capacity-driven secondary case, Slack Apps have no equivalent capacity limit β the secondary App scenario is rare in production and primarily exists for env separation.
12. Cost modelβ
Slack itself is free for the App side β Slack does not charge developers for creating Apps or for messages sent via Web API. Cost is on the customer side (their Slack workspace plan).
The only h.work-side cost dimensions:
- Slack API rate limits β operational cost, not financial. Identity sync must respect per-method per-workspace tier limits. Slack has signaled stricter rate-limit posture for non-Marketplace commercial apps in 2025+ changelogs; this is an operational scalability risk for the
unlisted_distributeddefault β see Β§13. - Slack Marketplace submission review β staff time, not vendor cost. Eligibility may include active-install minimums.
- Per-workspace bot user storage β counts against Slack workspace user cap (free workspaces have lower caps), but this is Org-side.
No entries in channel_pricing_rate_cards are required for Slack at launch. The table structure (from WhatsApp's design) supports adding Slack rows if Slack introduces paid platform features in the future.
13. Distribution mode decisionβ
Slack distinguishes three distribution states for an App. They are not interchangeable, and the terminology matters because Slack enforces installability based on it:
| Slack mode | Definition | Installable in customer workspaces? |
|---|---|---|
undistributed | Default state of a newly-created App. Only installable in the workspace where it was created. | β No |
unlisted_distributed | Distribution enabled in App settings but not submitted to Slack Marketplace. Installable in any workspace via direct OAuth URL. | β Yes, via URL |
marketplace_listed | Submitted and approved by Slack Marketplace. Discoverable by Slack search; eligible for verified-publisher badge. Requires multi-week Slack review and ongoing eligibility (e.g., minimum active installs). | β Yes, via URL or Marketplace search |
Default: unlisted_distributed. Each Specialist's App is distributed but not listed in the Slack Marketplace. AMs provide a direct OAuth install URL to customer workspace admins. This matches the h.work go-to-market model (customers are pre-existing, AM-onboarded) and avoids Marketplace review during launch while still allowing installation into customer workspaces.
The previous draft of this design used the term "internal-only" β that terminology is misleading because Slack's "internal" / undistributed mode does not support installing into external customer workspaces. The correct default is unlisted_distributed.
Opt-in upgrade to Marketplace: A select set of "flagship" Specialists may be promoted to marketplace_listed for inbound discovery (lead generation) or verified-publisher credibility. Promotion is a deliberate per-Specialist decision recorded as a persona state change and triggers Slack submission flow.
Operational risk: Slack's 2025+ posture suggests stricter rate-limit and policy enforcement for non-Marketplace commercial apps. If h.work operates a large fleet of unlisted_distributed Specialist Apps, Slack may at some point apply such limits or require Marketplace listing for continued operation. This is not a launch blocker but is a foreseeable platform risk; track in Β§15 open questions.
14. Security and compliance boundariesβ
| Boundary | Isolation provided by |
|---|---|
| Kai-Acme vs Kai-BigCorp (same Specialist, different Org) | Different workspace install + different team_id + different access_token + different endpoint row + different RLS GUC |
| Kai-Acme vs Mei-Acme (same Org, different Specialist) | Different App + different api_app_id + different signing_secret + different bot_user_id in same workspace |
| Kai-Acme vs Mei-BigCorp (different Org, different Specialist) | Both of the above, composed |
Threat model coverage:
- Per-install
access_tokencompromise β single OSA affected. Rotate by reinstalling that workspace (Slack issues new token). - App-level
signing_secretcompromise β all OSAs of that Specialist's App. Attacker could forge webhook payloads. Mitigation: signing_secret rotation is an App-level event; documented as incident-response runbook. Slack supports signing_secret rotation via App settings. - App-level
client_secretcompromise β attacker could complete OAuth flows impersonating h.work. Same mitigation: rotation event. tokens_revokedevent β must update endpoint state promptly; stale tokens become 401-rejected by Slack anyway, but DB state should reflect reality. Payload may include both bot and user token IDs; this design processes bot tokens only.- Bot user in workspace has access to private channels it's invited to β Org admins control which channels h.work bot can see. Document this expectation in customer onboarding.
14.4 OAuth scopes are additive (Slack-specific)β
Slack OAuth scopes accumulate across repeated installs of the same App into the same workspace. This has three consequences:
- Adding a scope requires re-running the OAuth consent flow with the workspace admin so they grant the new scope. The endpoint stays usable on the old scope set until reinstall completes.
- Removing a scope cannot be done by reinstalling with a smaller scope set. Slack does not revoke previously-granted scopes implicitly. Reducing scope requires explicit
auth.revokeof the existing token followed by a fresh install with the smaller scope. - Endpoint
metadata.installed_scopesrecords the currently granted scope set; it grows monotonically until an explicit revoke event resets it.
When a Specialist's manifest changes scopes (additions or removals), affected endpoints transition to a scopes_drifted warning surfaced in the AM ops dashboard, prompting reinstall-coordinated outreach to customer admins.
15. Open questions to verify against current Slack docsβ
Before implementation, the following facts must be confirmed against current Slack Platform documentation. Treat the design above as architecturally correct but vendor-API details are indicative:
- Current Slack Apps Manifest API surface (schema version, supported fields, whether App creation can be fully automated or still requires manual one-time steps in api.slack.com)
- Token rotation (
token_rotation_enabled) β required vs optional, default state; impact onbot_refresh_token/expires_athandling - Current rate-limit tiers per Web API method (specifically
users.profile.set,users.setPhoto,chat.postMessage); whether rate-limit policy differs forunlisted_distributedvsmarketplace_listedapps - Whether
signing_secretrotation procedure causes downtime in webhook verification (any overlap period for both old + new secrets?) - Slack Marketplace review SLA, current eligibility requirements (active install minimums, scope justification depth, support documentation)
- Enterprise Grid install semantics (
enterprise_idlifecycle, org-wide vs workspace-scoped tokens,is_enterprise_installfield) - Whether bot user
display_nameand avatar can be set per-workspace viausers.profile.set/users.setPhotofor the App's own bot user β Slack has restrictions on profile mutation depending on token type and workspace policy. This must be verified end-to-end before promising the identity-sync feature. - Whether the
app_uninstalledevent is guaranteed for every uninstall path (workspace deletion, plan downgrade, admin force-remove); fallback detection if not. - Best-practice for handling
tokens_revokedevents in active conversations (graceful degradation vs immediate hard fail) - OAuth scope grant model β confirm scopes are strictly additive and that scope reduction requires explicit revoke (see Β§14.4 assumption)
- Slack's 2025+ policy posture on non-Marketplace commercial apps β any documented rate-limit, audit, or visibility differences vs Marketplace-listed apps that would affect the
unlisted_distributeddefault
16. Implementation planβ
The work is sized for 4-6 PRs, sequenced as expand-contract. Coordinate with #1158 (Slack inbound module refactor) β the refactor's new module shape should align with this design.
Phase 1 β Schema (Expand)β
- Tables
specialist_channel_personas,osa_channel_endpoints,channel_pricing_rate_cardsfrom the parent ADR are reused. - Add Slack-specific composite index
uniq_slack_endpoint_app_teamon(persona_id, team_id). - Migration applies cleanly to active-active cluster.
Phase 2 β Backfill (read-only)β
- For each existing Org with Slack
integration_credentialsrow: create a persona row for the existing (single, shared) Slack App, plus an endpoint per OSA that maps to the same legacy token (markedshared_with_org=true). - Verify endpoint reverse-lookup by
(api_app_id, team_id)matches existing routing in shadow mode. - No production traffic switched.
Phase 3 β Slack App creation tooling + AM Setup Wizardβ
SlackPersonaService.createApp(specialistId)β calls Apps Manifest API or guides ops through manual creation.- AM Setup Wizard "Enable Slack for OSA" generates per-OSA OAuth install URL.
- OAuth callback handler exchanges code for token, configures bot profile, transitions endpoint to
provisioned.
Phase 4 β Inbound module refactor (couples with #1158)β
- New
SlackModule(per #1158) reads routing fromosa_channel_endpointsby(api_app_id, team_id). - Path-encoded
api_app_idin webhook URL. - Signing-secret verification reads from Secrets Manager via persona.
- 7-day production soak; alert on legacy fallback hits.
Phase 5 β Identity syncβ
@OnEvent('specialist.updated')listener with Slack processor.users.profile.set+users.setPhotoper endpoint.- Rate-limit-aware (Slack Tier 2-4); DLQ on persistent failure.
Phase 6 β Switch read + contractβ
- Remove legacy
integration_credentialsSlack rows after dual-write soak. - Degrade
slack_channel_bindingsto optional scope-hint table (dropspecialist_idrouting dependency). - Remove legacy lookup paths.
Phase 7 β Slack Marketplace submission (parallel, optional, per Specialist)β
- Coordinated with #1159.
- Per Specialist that opts into Marketplace distribution: submit App via Slack Marketplace.
- Persona
verification_statustracks review state (unlistedβin_reviewβmarketplace_approved). - Verify current Slack eligibility requirements (e.g., minimum active installs) before submitting.
17. ADR decision text (drop-in)β
For ADR-030 Β§Per-channel application / Slack:
For Slack, h.work uses an App-per-Specialist and install-per-OSA model.
Every Specialist that engages with Slack owns exactly one primary Slack App registered under h.work's Slack developer account. This is an h.work product-level identity and isolation invariant, not a Slack platform requirement. The App is the Specialist's Slack identity container, owning the Slack Marketplace listing (if listed), App manifest, OAuth scopes,
client_id/client_secret,signing_secret, and the bot user template.Every Org Γ Specialist assignment (OSA) receives one dedicated workspace install of that Specialist's App. Each install produces an independent
bot_user_idandaccess_token. The(api_app_id, team_id)tuple is the deterministic routing key for inbound events.Slack inbound events resolve to exactly one OSA via single-row lookup on
(persona_id, team_id). Signature verification using App-levelsigning_secretruns first;url_verificationevents are handled after signature verification but before endpoint resolution. All inbound webhooks ACK with HTTP 200 within Slack's 3-second budget and dispatch business processing to BullMQ. There is no second-step routing in the default model.The bot user identity in each workspace (display name + avatar) is configured at install time via Slack-supported profile APIs on a best-effort basis, sourced from the Specialist row. Profile sync failures do not gate provisioning and are surfaced in
profile_sync_statefor operator follow-up.
unlisted_distributedis the default distribution mode (App is distributable to customer workspaces via OAuth URL but not listed in Slack Marketplace). Slack Marketplace listing is opt-in per Specialist. The legacyslack_channel_bindingstable is degraded to an optional scope-hint mechanism and removed from routing.Slack uniqueness is enforced per persona:
UNIQUE(persona_id, external_id) WHERE channel_type='slack'. The parent ADR's global(channel_type, external_id)uniqueness does not apply to Slack because the same workspace may install multiple Specialist Apps.OAuth scopes are additive across grants; scope reduction requires explicit token revoke + reinstall.
A Specialist MAY own additional secondary Apps for staging, testing, or migration reasons. Secondary Apps do not change Specialist identity ownership.
Org-shared front-door bot (one bot per Org with internal topic-based routing to multiple Specialists) is deferred to ADR-028 and is not part of the default model. Enterprise Grid org-wide installs are a future extension with schema preparation in
metadata.enterprise_id/is_enterprise_install. Vendor-API specifics (Apps Manifest API shape, current rate-limit tiers, token rotation requirements, profile-mutation permission scope) are indicative and must be verified against current Slack documentation at implementation time.
18. Recommended implementation issueβ
Title: [P2][feat] Slack per-OSA install with App-per-Specialist identity model
Parent: #1471 (ADR-030)
Related: #1158 (Slack inbound refactor), #1159 (Slack Marketplace submission)
Labels: priority:p2 type:feature area:backend
Scope checklist:
- Phase 1: schema migration (shared with WhatsApp PR) + Slack-specific composite index
- Phase 2: backfill legacy Slack
integration_credentialsrows; shadow-mode verification - Phase 3:
SlackPersonaService+SlackInstallService+ AM Setup Wizard "Enable Slack" - Phase 4: inbound module refactor coordinating with #1158
- Phase 5: identity sync job (
@OnEvent('specialist.updated')+ Slack processor) - Phase 6: switch read + contract; degrade
slack_channel_bindingsto optional metadata - Phase 7: Slack Marketplace submission tooling (coordinating with #1159; optional per Specialist)
- Secondary App support (env / migration shard_key)
- OAuth state token (signed, single-use, 5-min TTL)
-
app_uninstalled/tokens_revokedevent handlers - Audit log: install + reinstall + uninstall + revocation events
- Operational dashboards: persona state + install state aggregates
Verification gates before merge:
- All Slack Web API calls behind
withCircuit + withRetry+channel_failuresDLQ - Inbound handler rejects unresolved
(api_app_id, team_id)with 404 - Signing-secret verification uses per-App secret from Secrets Manager
- Identity sync respects Slack rate limits via config-driven throttle
app_uninstalledevent correctly transitions endpoint tosuspended(notrevoked)tokens_revokedevent correctly transitions endpoint torevoked- E2E test: install Kai App into two different workspaces, verify isolation
- E2E test: install both Kai-App and Mei-App into the same workspace, verify routing by
api_app_id - E2E test: signing_secret rotation propagates to verification code without downtime
One-line summaryβ
One Slack App per Specialist as platform-level identity container; one workspace install per OSA producing independent bot_user_id + access_token; inbound always resolves single OSA via (persona_id, team_id) after App-level signing-secret verification; identity sourced from Specialist row, synced best-effort to bot profiles; unlisted_distributed is the default distribution mode with Slack Marketplace listing opt-in; slack_channel_bindings degraded from routing primitive to optional scope hint.