Skip to main content

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_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 (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_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.