Skip to main content

Slack Per-OSA Install with App-per-Specialist Identity Model

⚠️ Draft — not yet implemented. This subdocument proposes tables and workflows that do not exist in any environment. Slack channel credentials are currently per-Org (integration_credentials). The osa_channel_endpoints table and per-OSA Slack install flow proposed here are design proposals only.

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_id
  • specialist_id
  • channel_endpoint_id
  • conversation scope (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

FieldSourcePer-App or per-install
Marketplace listing name"{Specialist.firstName} by h.work"per-App (Specialist-level)
App icon (Marketplace listing)specialists.avatar_urlper-App
Bot user display namespecialists.firstName (default) or per-OSA overrideper-install (OSA-level), best-effort
Bot user avatarspecialists.avatar_urlper-install, synced (best-effort)
Bot user status / "what I do"specialists.description (truncated)per-install, best-effort
OAuth scopesDefined in App manifestper-App
Event subscriptionsDefined in App manifestper-App
Slash commandsDefined in App manifestper-App
Redirect URIDefined in App manifestper-App
signing_secretGenerated at App creationper-App
client_id / client_secretGenerated at App creationper-App
access_token (bot token)OAuth exchange at installper-install (OSA-level)
bot_user_idReturned at installper-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:

ObjectCount formulaBound
Slack App (primary)M = number of Specialists with Slackone per Specialist
Slack App (secondary)0..K per Specialistexception-driven
Workspace installsum(OSAs per Specialist with Slack enabled)one per OSA
Endpoint rowidentical to install countone 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):

ConditionResponse
Signature invalid / timestamp drift401
Signature valid + url_verification200 with challenge
Signature valid + endpoint resolves200 (ACK only); async worker handles business logic
Signature valid + unknown endpoint, normal message200 + 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_id401 (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 include bot and/or user token IDs; only bot is processed by this design. user token 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:

  1. Resolves endpoint → persona → identity
  2. Attempts users.profile.set on the App's own bot user with new display name / status
  3. Attempts users.setPhoto for avatar
  4. 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)
  5. Records per-field result in osa_channel_endpoints.profile_sync_state JSONB:
    {
    "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" }
    }
  6. 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_id for Slack endpoints; uniqueness is enforced within persona_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_secret lives in Secrets Manager keyed by persona, not endpoint — App-level.
  • bot_access_token lives in Secrets Manager keyed by endpoint — install-level.
  • metadata.team_id is intentionally mirrored from external_id for SQL convenience in queries that join across channels (avoids WHERE 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_received with explicit error; AM re-issues install URL.
  • tokens_revoked from Slack → endpoint moves to revoked; AM must re-install to restore. Outbound dispatch is blocked from any non-provisioned endpoint.
  • app_uninstalled from Slack → endpoint moves to suspended; the workspace's token is invalidated by Slack, so suspended means "relationship restorable via reinstall," not "credentials still usable." Re-install reactivates (existing conversations remain associated by (persona_id, team_id)).
  • profile_sync_state failures do not transition provisioning_status; endpoint stays provisioned, sync job retries via DLQ. Persistent unsupported is 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:

FeatureWhereWhy 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 subsystemADR-028
Slack Connect cross-workspace channelsNot relevanth.work is not the inter-org link in customer Slack Connect setups.
Slack Enterprise Grid org-wide installsFuture extensionSingle-workspace install model first; Enterprise Grid via enterprise_id field on endpoint metadata is a follow-up.
Per-Org bot display name customization at installOptional opt-inThe sender_display_name column supports it; not promoted as default.
slack_channel_bindings tableDegrade to optional metadataHistorically 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:

Reasonshard_key valueUse case
Stagingenv:stagingSeparate App for staging environment so dev installs don't pollute production App's install list
Testingenv:testDedicated App for e2e tests against real Slack workspaces
Migrationmigration:<reason>Transitioning between manifest versions that require re-install (e.g., new scopes)
Regionregion:<ISO>Rare; only relevant if Slack adds regional App namespaces in future

Rules:

  • Each Specialist has exactly one primary App with role='primary' and shard_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_distributed default — 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 modeDefinitionInstallable in customer workspaces?
undistributedDefault state of a newly-created App. Only installable in the workspace where it was created.❌ No
unlisted_distributedDistribution enabled in App settings but not submitted to Slack Marketplace. Installable in any workspace via direct OAuth URL.✅ Yes, via URL
marketplace_listedSubmitted 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

BoundaryIsolation 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_token compromise → single OSA affected. Rotate by reinstalling that workspace (Slack issues new token).
  • App-level signing_secret compromise → 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_secret compromise → attacker could complete OAuth flows impersonating h.work. Same mitigation: rotation event.
  • tokens_revoked event → 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:

  1. 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.
  2. 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.revoke of the existing token followed by a fresh install with the smaller scope.
  3. Endpoint metadata.installed_scopes records 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:

  1. 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)
  2. Token rotation (token_rotation_enabled) — required vs optional, default state; impact on bot_refresh_token / expires_at handling
  3. Current rate-limit tiers per Web API method (specifically users.profile.set, users.setPhoto, chat.postMessage); whether rate-limit policy differs for unlisted_distributed vs marketplace_listed apps
  4. Whether signing_secret rotation procedure causes downtime in webhook verification (any overlap period for both old + new secrets?)
  5. Slack Marketplace review SLA, current eligibility requirements (active install minimums, scope justification depth, support documentation)
  6. Enterprise Grid install semantics (enterprise_id lifecycle, org-wide vs workspace-scoped tokens, is_enterprise_install field)
  7. Whether bot user display_name and avatar can be set per-workspace via users.profile.set / users.setPhoto for 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.
  8. Whether the app_uninstalled event is guaranteed for every uninstall path (workspace deletion, plan downgrade, admin force-remove); fallback detection if not.
  9. Best-practice for handling tokens_revoked events in active conversations (graceful degradation vs immediate hard fail)
  10. OAuth scope grant model — confirm scopes are strictly additive and that scope reduction requires explicit revoke (see §14.4 assumption)
  11. 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_distributed default

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_cards from the parent ADR are reused.
  • Add Slack-specific composite index uniq_slack_endpoint_app_team on (persona_id, team_id).
  • Migration applies cleanly to active-active cluster.

Phase 2 — Backfill (read-only)

  • For each existing Org with Slack integration_credentials row: create a persona row for the existing (single, shared) Slack App, plus an endpoint per OSA that maps to the same legacy token (marked shared_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 from osa_channel_endpoints by (api_app_id, team_id).
  • Path-encoded api_app_id in 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.setPhoto per endpoint.
  • Rate-limit-aware (Slack Tier 2-4); DLQ on persistent failure.

Phase 6 — Switch read + contract

  • Remove legacy integration_credentials Slack rows after dual-write soak.
  • Degrade slack_channel_bindings to optional scope-hint table (drop specialist_id routing 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_status tracks review state (unlistedin_reviewmarketplace_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_id and access_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-level signing_secret runs first; url_verification events 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_state for operator follow-up.

unlisted_distributed is 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 legacy slack_channel_bindings table 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.


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_credentials rows; 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_bindings to 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_revoked event 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_failures DLQ
  • 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_uninstalled event correctly transitions endpoint to suspended (not revoked)
  • tokens_revoked event correctly transitions endpoint to revoked
  • 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.