# SlackWorkspaceIntegration

## Description

SlackWorkspaceIntegration is the singleton record of a Slack app installation that enables the `SLACK` DESTINATION channel. The tenant (the whole deployment) holds at most one connection: the row stores the Slack `teamId` (unique), optional `teamName` and `botUserId`, a lifecycle `status` (`ACTIVE` | `REVOKED`), and the `installedAt` / `lastValidatedAt` / `revokedAt` timestamps. The bot token is **not stored on this row at all**: `completeSlackWorkspaceInstall` returns the plaintext token to the calling resolver, which persists it in the host app's secret manager (keyed by `teamId`); the Slack DestinationAdapter reads it back from secret management at send time.

This model owns only the workspace-level install state; the per-target channel routing (which Slack channel a given source entity posts to) lives on the generalized `ChannelRoutingBinding`, not here. The Slack DestinationAdapter consults this row before every post: a missing or `REVOKED` connection makes the post fail fast (recorded on `DestinationDeliveryLog`) rather than calling the Slack API with a dead token.

The model's **GraphQL auto-CRUD is closed entirely** (create/read/update/delete all disabled): rows are written only by the install/uninstall commands through the in-transaction db handle, and the row carries no token column to begin with. The only read surface is the redacted `getSlackWorkspaceIntegration` projection (metadata only).

The connection is a singleton: an install that completes while the existing `ACTIVE` row is bound to a different `teamId` is rejected with `TEAM_ALREADY_CONNECTED`, so the tenant never silently swaps to (or holds tokens for) a second workspace. Revocation (`app_uninstalled`) flips `status` to `REVOKED` and stamps `revokedAt` but keeps the row, so re-install reuses the same row with a fresh token.

## Domain Model Definitions

### Model type

Stateful

#### State Transitions

```mermaid
stateDiagram-v2
    [*] --> ACTIVE: completeSlackWorkspaceInstall
    ACTIVE --> ACTIVE: completeSlackWorkspaceInstall (re-install / token refresh)
    ACTIVE --> REVOKED: handleSlackAppUninstalled
    REVOKED --> ACTIVE: completeSlackWorkspaceInstall (re-connect)
```

| Operation    | From            | To      | Command                                                                       |
| ------------ | --------------- | ------- | ----------------------------------------------------------------------------- |
| Install      | REVOKED         | ACTIVE  | [completeSlackWorkspaceInstall](../command/CompleteSlackWorkspaceInstall.md) |
| TokenRefresh | ACTIVE          | ACTIVE  | [completeSlackWorkspaceInstall](../command/CompleteSlackWorkspaceInstall.md) |
| Revoke       | ACTIVE          | REVOKED | [handleSlackAppUninstalled](../command/HandleSlackAppUninstalled.md)         |

### Command Definitions

- [beginSlackWorkspaceInstall](../command/BeginSlackWorkspaceInstall.md) - Produce the Slack OAuth authorize URL with a signed `state` token carrying `{ userId, redirectUri, expiresAt }` (no row written)
- [completeSlackWorkspaceInstall](../command/CompleteSlackWorkspaceInstall.md) - Exchange the OAuth `code` for a bot token, upsert the tenant's `ACTIVE` connection (metadata only), and return the plaintext token to the resolver for secret-manager storage
- [handleSlackAppUninstalled](../command/HandleSlackAppUninstalled.md) - Mark the workspace `REVOKED` when Slack sends `app_uninstalled`

### Query Definitions

- [getSlackWorkspaceIntegration](../query/GetSlackWorkspaceIntegration.md) - Read the tenant's connection as redacted metadata (status, teamId, teamName) — never the bot token

### Models

- SlackWorkspaceIntegration

### Invariants

- The connection is a singleton enforced at the **application level** (TailorDB cannot express a constant-field unique index): the install command picks the existing row deterministically via `orderBy(createdAt)` (oldest wins) and `teamId` is unique; a residual race lets two concurrent installs against an empty table both insert
- An install completing while the existing `ACTIVE` connection is bound to a different `teamId` is rejected with `TEAM_ALREADY_CONNECTED`; the conflicting workspace must be uninstalled first
- The bot token is **not a column on this model**; it is held in the host secret manager (keyed by `teamId`) and read by the Slack DestinationAdapter at send time, so no gateway query can ever select it
- GraphQL auto-CRUD is fully closed on this model; reads go through the redacted `getSlackWorkspaceIntegration` query only (metadata)
- `status = ACTIVE` is required for the Slack DestinationAdapter to attempt a post; a `REVOKED` (or absent) connection fails the post fast
- Revocation keeps the row (`status = REVOKED`, `revokedAt` stamped) so re-install reuses the same row and existing `ChannelRoutingBinding` rows remain valid when `teamId` is unchanged
- `installedAt` is stamped on first install and never overwritten; `lastValidatedAt` advances on each successful (re-)install
- Reactivation from `REVOKED` requires a fresh successful OAuth install, never a delivery retry

### Relationships

- **Read by the Slack DestinationAdapter**: resolves the tenant's `ACTIVE` connection (for status + `teamId`) and reads the bot token from the host secret manager before calling `chat.postMessage`
- **Independent of ChannelRoutingBinding**: this row carries workspace-level install state; per-target Slack channel routing lives on `ChannelRoutingBinding(targetType, targetId, channelId = "SLACK", externalChannelRef)`
- **Drives DestinationDeliveryLog failure reasons**: a missing / revoked connection produces a `FAILED` DestinationDeliveryLog row with `failureReason = workspace_not_connected`
