# Capability Spec Delta: slack-client (add-slack-client) **Package**: `@suaveplan/slack-client` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: SlackApiClient sends messages via `chat.postMessage` The package SHALL expose `SlackApiClient.sendMessage(message)` which POSTs to `${baseUrl}/chat.postMessage` with an `Authorization: Bearer ` header and a JSON body containing `channel`, `text`, and the optional `blocks`, `thread_ts`, `unfurl_links`, `unfurl_media`, `metadata` fields. On a successful Slack response carrying `ok: true` and `ts`, the client SHALL record a `SlackDeliveryReport { status: "delivered" }` retrievable via `getStatus(ts)`. #### Scenario: successful send populates delivery report - **WHEN** the caller invokes `sendMessage({ channel: "C1", text: "hi" })` and the fake fetch resolves `{ ok: true, channel: "C1", ts: "99.99" }` - **THEN** the returned `SlackResponse.ok` is `true`, `getStatus("99.99")` returns a `delivered` report with channel `C1`, and the OTel `slack.requests` counter increments once with `{ method: "chat.postMessage", outcome: "ok" }` #### Scenario: blocks + thread + metadata forwarded verbatim - **WHEN** `sendMessage` is called with `blocks`, `threadTs`, `unfurlLinks`, `unfurlMedia`, `metadata` - **THEN** the captured request body contains `thread_ts`, `unfurl_links`, `unfurl_media`, `blocks`, `metadata` matching the input #### Scenario: default channel used when message channel is empty - **WHEN** the client is constructed with `defaultChannel: "C999"` and `sendMessage({ channel: "", text: "hi" })` is invoked - **THEN** the request body posts to channel `C999` #### Scenario: no channel throws `SLACK.CHANNEL_NOT_FOUND` - **WHEN** `sendMessage({ channel: "", text: "hi" })` is invoked with no `defaultChannel` - **THEN** the call rejects with `SlackChannelNotFoundError` carrying code `SLACK.CHANNEL_NOT_FOUND` **Verified by:** `src/client/slack-api-client.test.ts::"sendMessage posts to chat.postMessage and records a delivered report"` AND `src/client/slack-api-client.test.ts::"sendMessage forwards blocks thread metadata and unfurl flags"` AND `src/client/slack-api-client.test.ts::"sendMessage uses defaultChannel when message.channel is empty"` AND `src/client/slack-api-client.test.ts::"sendMessage throws SLACK.CHANNEL_NOT_FOUND when no channel is available"` --- ### Requirement: SlackApiClient updates and deletes messages The package SHALL expose `updateMessage(channel, ts, text, blocks?)` and `deleteMessage(channel, ts)` which POST to `chat.update` and `chat.delete` respectively with the supplied identifiers. #### Scenario: updateMessage forwards new text and optional blocks - **WHEN** `updateMessage("C1", "1.1", "Updated", blocks)` is invoked - **THEN** the request body contains `channel: "C1"`, `ts: "1.1"`, `text: "Updated"`, and the supplied `blocks` #### Scenario: deleteMessage forwards channel and ts - **WHEN** `deleteMessage("C1", "1.1")` is invoked - **THEN** the request body contains exactly `{ channel: "C1", ts: "1.1" }` **Verified by:** `src/client/slack-api-client.test.ts::"updateMessage posts to chat.update with new text and blocks"` AND `src/client/slack-api-client.test.ts::"deleteMessage posts to chat.delete with channel and ts"` --- ### Requirement: SlackApiClient lists channels and users with mapping The package SHALL expose `listChannels(limit?)`, `getChannelInfo(channelId)`, and `listUsers(limit?)`. The client SHALL map snake_case Slack payload shapes (`is_private`, `topic.value`, `purpose.value`, `num_members`, `real_name`, `profile.email`, `is_bot`) onto camelCase domain types (`SlackChannel`, `SlackUser`). Missing arrays SHALL yield `[]`, not `undefined`. #### Scenario: listChannels maps snake_case to domain - **WHEN** the fake fetch returns `{ ok: true, channels: [{ id, name, is_private, topic, purpose, num_members }, ...] }` - **THEN** the result is `SlackChannel[]` with camelCase fields populated #### Scenario: listChannels returns empty array when key absent - **WHEN** the fake fetch returns `{ ok: true }` with no `channels` key - **THEN** the result is `[]` #### Scenario: getChannelInfo maps single channel - **WHEN** `getChannelInfo("C1")` is invoked and the fake fetch returns a channel object - **THEN** the returned `SlackChannel` matches the mapped shape #### Scenario: listUsers maps snake_case to domain - **WHEN** the fake fetch returns `{ ok: true, members: [...] }` - **THEN** the result is `SlackUser[]` with `realName`, `email`, `isBot` populated #### Scenario: listUsers returns empty array when key absent - **WHEN** the fake fetch returns `{ ok: true }` with no `members` key - **THEN** the result is `[]` **Verified by:** `src/client/slack-api-client.test.ts::"listChannels maps snake_case payload to SlackChannel"` AND `src/client/slack-api-client.test.ts::"listChannels returns empty array when channels key is absent"` AND `src/client/slack-api-client.test.ts::"getChannelInfo maps a single channel"` AND `src/client/slack-api-client.test.ts::"listUsers maps snake_case payload to SlackUser"` AND `src/client/slack-api-client.test.ts::"listUsers returns empty array when members key is absent"` --- ### Requirement: SlackApiClient classifies HTTP errors into typed Slack codes The package SHALL convert HTTP 401/403 to `SLACK.AUTH_ERROR`, HTTP 404 to `SLACK.CHANNEL_NOT_FOUND`, HTTP 408/504 to `SLACK.TIMEOUT`, HTTP 429 to `SLACK.RATE_LIMITED` (with `retryAfterMs` populated from `Retry-After` or `x-ratelimit-reset`), and other non-2xx to `SLACK.API_ERROR`. A non-`ok: true` JSON body containing `error: "channel_not_found" | "is_archived" | "not_in_channel"` SHALL yield `SLACK.CHANNEL_NOT_FOUND`; `invalid_auth | not_authed | token_revoked | account_inactive` SHALL yield `SLACK.AUTH_ERROR`; `team_not_found | no_workspace` SHALL yield `SLACK.WORKSPACE_NOT_FOUND`; everything else SHALL yield `SLACK.API_ERROR`. #### Scenario: HTTP 401 yields SLACK.AUTH_ERROR - **WHEN** the fake fetch returns HTTP 401 - **THEN** the call rejects with `SlackAuthError` carrying code `SLACK.AUTH_ERROR` #### Scenario: HTTP 404 yields SLACK.CHANNEL_NOT_FOUND - **WHEN** the fake fetch returns HTTP 404 - **THEN** the call rejects with `SlackChannelNotFoundError` #### Scenario: HTTP 429 yields SLACK.RATE_LIMITED with parsed retry-after - **WHEN** the fake fetch returns HTTP 429 with `Retry-After: "30"` - **THEN** the rejection is `SlackRateLimitedError` whose `context.retryAfterMs` is `30000` #### Scenario: HTTP 500 yields SLACK.API_ERROR - **WHEN** the fake fetch returns HTTP 500 - **THEN** the call rejects with `SlackApiError` #### Scenario: Slack error string classifies to typed code - **WHEN** the fake fetch returns `{ ok: false, error: "channel_not_found" }` - **THEN** the call rejects with `SlackChannelNotFoundError` #### Scenario: invalid_auth string yields SLACK.AUTH_ERROR - **WHEN** the fake fetch returns `{ ok: false, error: "invalid_auth" }` - **THEN** the call rejects with `SlackAuthError` #### Scenario: team_not_found string yields SLACK.WORKSPACE_NOT_FOUND - **WHEN** the fake fetch returns `{ ok: false, error: "team_not_found" }` - **THEN** the call rejects with `SlackWorkspaceNotFoundError` #### Scenario: unknown error string yields SLACK.API_ERROR - **WHEN** the fake fetch returns `{ ok: false, error: "ratelimited_internal" }` - **THEN** the call rejects with `SlackApiError` **Verified by:** `src/client/classify.test.ts::"HTTP 401 classifies to SLACK.AUTH_ERROR"` AND `src/client/classify.test.ts::"HTTP 404 classifies to SLACK.CHANNEL_NOT_FOUND"` AND `src/client/classify.test.ts::"HTTP 500 classifies to SLACK.API_ERROR"` AND `src/client/classify.test.ts::"slack error string channel_not_found maps to SLACK.CHANNEL_NOT_FOUND"` AND `src/client/classify.test.ts::"slack error string invalid_auth maps to SLACK.AUTH_ERROR"` AND `src/client/classify.test.ts::"slack error string team_not_found maps to SLACK.WORKSPACE_NOT_FOUND"` AND `src/client/classify.test.ts::"unknown slack error string falls back to SLACK.API_ERROR"` AND `src/client/slack-api-client.test.ts::"HTTP 429 produces SlackRateLimitedError with parsed retryAfterMs"` --- ### Requirement: Retry-After header parsing supports three forms The package SHALL parse the `Retry-After` HTTP header in delay-seconds (`"30"`) and HTTP-date (`"Wed, 21 Oct 2026 07:28:00 GMT"`) forms, and fall back to `x-ratelimit-reset` in epoch-seconds, epoch-milliseconds, or ISO-8601 form. The parser SHALL return `undefined` when no header is present or the value is unparseable, and SHALL clamp negative deltas to `0`. #### Scenario: delay-seconds form - **WHEN** the response carries `Retry-After: "45"` - **THEN** `parseRetryAfterMs` returns `45000` #### Scenario: HTTP-date form - **WHEN** the response carries `Retry-After: ` and the injected clock is at the present - **THEN** the parser returns a value `>= 0` (≈60000) #### Scenario: x-ratelimit-reset epoch seconds fallback - **WHEN** no `Retry-After` is present but `x-ratelimit-reset` is epoch seconds in the future - **THEN** the parser returns the difference in milliseconds #### Scenario: x-ratelimit-reset ISO fallback - **WHEN** no `Retry-After` is present but `x-ratelimit-reset` is an ISO timestamp in the future - **THEN** the parser returns the difference in milliseconds #### Scenario: missing headers yields undefined - **WHEN** neither header is present - **THEN** the parser returns `undefined` #### Scenario: negative delta clamps to 0 - **WHEN** the header points at a past timestamp - **THEN** the parser returns `0` **Verified by:** `src/client/retry-after.test.ts::"parses delay-seconds form"` AND `src/client/retry-after.test.ts::"parses HTTP-date form"` AND `src/client/retry-after.test.ts::"falls back to x-ratelimit-reset epoch seconds"` AND `src/client/retry-after.test.ts::"falls back to x-ratelimit-reset ISO string"` AND `src/client/retry-after.test.ts::"returns undefined when no headers are present"` AND `src/client/retry-after.test.ts::"clamps negative deltas to 0"` --- ### Requirement: SlackApiClient enforces AbortSignal-driven timeout The package SHALL abort an in-flight Slack request after `timeoutMs` (default `10000`) and surface the abort as `SlackTimeoutError` (code `SLACK.TIMEOUT`). Caller-supplied `AbortSignal` SHALL also terminate the request and propagate. #### Scenario: internal timeout surfaces SlackTimeoutError - **WHEN** the fake fetch never resolves and `timeoutMs: 1` is configured - **THEN** the call rejects with `SlackTimeoutError` #### Scenario: caller signal aborts the request - **WHEN** the caller passes a pre-aborted `AbortSignal` - **THEN** the call rejects with `SlackTimeoutError` whose `context.url` matches **Verified by:** `src/client/slack-api-client.test.ts::"timeoutMs aborts an in-flight request with SlackTimeoutError"` AND `src/client/slack-api-client.test.ts::"caller-supplied AbortSignal cancels the request with SlackTimeoutError"` --- ### Requirement: SlackWebhook posts to an incoming webhook URL The package SHALL expose `SlackWebhook.send(message)` and `SlackWebhook.sendText(text)`. Both SHALL POST a JSON body with `text` and optional `blocks` to the configured `webhookUrl` with `Content-Type: application/json; charset=utf-8`. On 2xx, the returned `SlackDeliveryReport.status` SHALL be `"delivered"`. On non-2xx, the call SHALL reject with `SlackAuthError` (401/403) or `SlackWebhookFailedError` (otherwise). On network failure or timeout, the call SHALL reject with `SlackTimeoutError`. #### Scenario: send returns delivered report on 2xx - **WHEN** the fake fetch returns HTTP 200 - **THEN** the report's status is `"delivered"`, channel `"webhook"`, and `messageId` is non-empty #### Scenario: sendText forwards plain text - **WHEN** `sendText("hi")` is invoked - **THEN** the request body parses to `{ text: "hi" }` with no `blocks` #### Scenario: 401 yields SlackAuthError - **WHEN** the fake fetch returns HTTP 401 - **THEN** the call rejects with `SlackAuthError` #### Scenario: 400 yields SlackWebhookFailedError - **WHEN** the fake fetch returns HTTP 400 with body `"invalid_payload"` - **THEN** the call rejects with `SlackWebhookFailedError` carrying the body in `context` #### Scenario: message IDs are monotonic - **WHEN** two successive sends complete - **THEN** their `messageId`s differ **Verified by:** `src/webhook/slack-webhook.test.ts::"send returns a delivered report on HTTP 200"` AND `src/webhook/slack-webhook.test.ts::"sendText posts only the text field"` AND `src/webhook/slack-webhook.test.ts::"HTTP 401 rejects with SlackAuthError"` AND `src/webhook/slack-webhook.test.ts::"HTTP 400 rejects with SlackWebhookFailedError"` AND `src/webhook/slack-webhook.test.ts::"sequential sends yield distinct messageIds"` --- ### Requirement: Constructors validate configuration via Zod The package SHALL reject construction of `SlackApiClient` with an empty `token` and `SlackWebhook` with an empty `webhookUrl`, throwing `SlackConfigMissingError` (code `SLACK.CONFIG_MISSING`) at construct time. All public option bundles SHALL be validated through a Zod schema; safeParse SHALL surface a `ZodError` cause on the thrown `SlackConfigMissingError`. #### Scenario: empty token rejected - **WHEN** `new SlackApiClient({ token: "" })` is invoked - **THEN** the constructor throws `SlackConfigMissingError` #### Scenario: empty webhookUrl rejected - **WHEN** `new SlackWebhook({ webhookUrl: "" })` is invoked - **THEN** the constructor throws `SlackConfigMissingError` #### Scenario: schemas are exposed for boundary parse - **WHEN** `SlackMessageSchema.safeParse({ channel: 1, text: "x" })` is invoked - **THEN** `success` is `false` **Verified by:** `src/client/slack-api-client.test.ts::"empty token rejects with SlackConfigMissingError"` AND `src/webhook/slack-webhook.test.ts::"empty webhookUrl rejects with SlackConfigMissingError"` AND `src/schemas/schemas.test.ts::"SlackMessageSchema rejects non-string channel"` --- ### Requirement: OTel instruments emit on every Slack request The package SHALL emit four OpenTelemetry instruments on every production path: a `slack.requests` counter, a `slack.errors` counter, a `slack.latency_ms` histogram, and a `slack.webhook_sends` counter. Counters SHALL carry attributes `{ method, outcome }` where `outcome ∈ { "ok", "error" }`. #### Scenario: successful sendMessage emits slack.requests + slack.latency_ms - **WHEN** `sendMessage` succeeds - **THEN** the in-memory `MeterProvider` records one `slack.requests` increment with `outcome: "ok"` and one `slack.latency_ms` observation #### Scenario: failed sendMessage emits slack.requests + slack.errors - **WHEN** `sendMessage` rejects with `SlackApiError` - **THEN** the in-memory `MeterProvider` records one `slack.requests` with `outcome: "error"` and one `slack.errors` increment #### Scenario: webhook send emits slack.webhook_sends - **WHEN** `SlackWebhook.send` succeeds - **THEN** the in-memory `MeterProvider` records one `slack.webhook_sends` increment with `outcome: "ok"` **Verified by:** `src/otel/metrics.test.ts::"successful sendMessage emits slack.requests with outcome ok"` AND `src/otel/metrics.test.ts::"failed sendMessage emits slack.requests outcome error and slack.errors"` AND `src/otel/metrics.test.ts::"webhook send emits slack.webhook_sends"` --- ### Requirement: Stable error code registry The package SHALL export `SLACK_CODES` (built via `defineCodes("SLACK", [...])`) containing eight stable codes: `API_ERROR`, `AUTH_ERROR`, `RATE_LIMITED`, `CHANNEL_NOT_FOUND`, `WORKSPACE_NOT_FOUND`, `CONFIG_MISSING`, `WEBHOOK_FAILED`, `TIMEOUT`. Each code SHALL have a typed `SuaveplanError` subclass and SHALL be thrown by at least one production path. `src/index.ts` SHALL side-effect import `./error-codes.js` so subpath consumers register codes too. #### Scenario: SLACK_CODES contains all eight reasons - **WHEN** consumer imports `SLACK_CODES` - **THEN** the record has exactly eight keys mapping reason → `SLACK.` #### Scenario: each error subclass carries its code - **WHEN** an instance of `SlackApiError`, `SlackAuthError`, `SlackRateLimitedError`, `SlackChannelNotFoundError`, `SlackWorkspaceNotFoundError`, `SlackConfigMissingError`, `SlackWebhookFailedError`, or `SlackTimeoutError` is constructed - **THEN** its `.code` equals the corresponding `SLACK.` literal **Verified by:** `src/errors/errors.test.ts::"SLACK_CODES exposes exactly the eight stable reasons"` AND `src/errors/errors.test.ts::"every error subclass carries its SLACK. code"` --- ### Requirement: Deterministic FakeFetch test double The package SHALL ship a `FakeFetch` under `src/fakes/fake-fetch.ts` which the test suite uses to drive every transport-level scenario without monkey-patching `globalThis.fetch` and without `vi.mock`. `FakeFetch` SHALL queue scripted responses, record every request, and surface a `FetchLike` instance for direct injection into client constructors. #### Scenario: queued response is consumed in FIFO order - **WHEN** two responses are queued and two requests are made - **THEN** the first request receives the first queued response #### Scenario: pending request abort propagates to AbortError - **WHEN** a request is queued as `pending` and the caller aborts the signal - **THEN** the returned promise rejects with `AbortError` **Verified by:** `src/fakes/fake-fetch.test.ts::"queued responses are consumed in FIFO order"` AND `src/fakes/fake-fetch.test.ts::"pending request rejects with AbortError when signal aborts"`