|
| 1 | +# ADR-0022: Connectors vs Messaging Channels — Where "Push to Slack" Lives |
| 2 | + |
| 3 | +**Status**: Proposed (2026-05-31) |
| 4 | +**Deciders**: ObjectStack Protocol Architects |
| 5 | +**Builds on**: [ADR-0012](./0012-notification-platform.md) (Messaging Platform — outbound `notify` on a generalized outbox), [ADR-0013](./0013-bidirectional-messaging.md) (Bidirectional Messaging — Slack first), [ADR-0015](./0015-external-datasource-federation.md) (open mechanism / enterprise lifecycle split), [ADR-0018](./0018-unified-node-action-registry.md) (`connector_action` as baseline generic dispatch) |
| 6 | +**Consumers**: `@objectstack/spec` (`integration/`, future `messaging/`), `@objectstack/services/service-automation` (connector registry), future `@objectstack/service-messaging`, future `@objectstack/plugin-messaging-slack`, `@objectstack/connectors/*` |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## TL;DR |
| 11 | + |
| 12 | +The question "should pushing a message to Slack be a **connector**?" surfaces a real collision in the repo: **Slack now has two candidate homes.** |
| 13 | + |
| 14 | +- **ADR-0012/0013** designate Slack as a **`MessagingChannel`** — a human-facing notification + conversation surface with a preference matrix, an inbox, an outbox, sessions/threads, and an inbound receiver. *Both ADRs are Draft; none of `service-messaging`, the `notify` transport, or `plugin-messaging-slack` is built yet.* |
| 15 | +- **ADR-0015/0018 + the just-merged connector baseline** give us a **`Connector`** — a generic "call any external system's API" mechanism (`connector_action` node, `engine.registerConnector()`, `@objectstack/connector-rest`). *This is built and live.* |
| 16 | + |
| 17 | +This ADR resolves the collision instead of letting two seams quietly grow a second Slack integration. The decision: |
| 18 | + |
| 19 | +1. **A `Connector` is the transport/integration mechanism** — "talk to system X's API," no recipient semantics. A `MessagingChannel` is the **human-messaging semantic layer** — recipients, preferences, inbox, outbox, sessions, inbound. They are **different abstractions at different layers**, exactly as "SMTP transport" ≠ "send a notification" in ADR-0012. |
| 20 | +2. **"Notify a human via Slack" is NOT a connector** — it is a `MessagingChannel` (ADR-0012/0013). Modeling it as a bare `connector_action` would silently drop preferences, the inbox, retry/dead-letter, and the reply-in-thread story, and would **duplicate ADR-0012**. |
| 21 | +3. **"A flow makes a raw Slack API call" IS a connector action** — `connector_action(connectorId:'slack', actionId:'chat.postMessage', …)`. This is the generic-integration path and works on **today's** baseline with no new abstraction. |
| 22 | +4. **The two share one transport.** A Slack `MessagingChannel.send()` should be **implemented on top of** a Slack `Connector` (auth, base URL, rate-limit, action set live once, in the connector); the channel adds the messaging semantics on top. Connector = mechanism; channel = policy. This generalizes ADR-0012's own "email becomes a transport sub-system" pattern. |
| 23 | + |
| 24 | +Because ADR-0012/0013 are still Draft, folding the connector baseline in underneath them is a **cheap revision, not a migration**. |
| 25 | + |
| 26 | +--- |
| 27 | + |
| 28 | +## Context |
| 29 | + |
| 30 | +### The two seams, side by side |
| 31 | + |
| 32 | +| Dimension | `Connector` (ADR-0015/0018, **built**) | `MessagingChannel` (ADR-0012/0013, **draft, unbuilt**) | |
| 33 | +|:---|:---|:---| |
| 34 | +| Purpose | call any external system's API | deliver to / converse with a **human** | |
| 35 | +| Primitive | an **action** (a verb on a system) | a **channel** (a delivery medium for a person) | |
| 36 | +| Recipient model | none — caller supplies explicit inputs | per-user preference matrix, mute, quiet hours, recipient resolver | |
| 37 | +| Delivery guarantee | one call → success/fail | outbox, retry, dead-letter, cluster-lock | |
| 38 | +| Threads / sessions | none | `sessionKey`, reply-to-thread, `sys_message_mirror` | |
| 39 | +| Inbound | generic webhook trigger | verify+dedup+parse → `target-resolver` → flow / hook / AI tool | |
| 40 | +| Multi-account | not modeled | `sys_channel_account`, first-class, multi-tenant | |
| 41 | +| Identity mapping | none | `sys_channel_user_link` (Slack user ↔ `sys_user`) | |
| 42 | +| Status today | **shipped** (`connector_action`, `@objectstack/connector-rest`) | **Draft** (`notify` is a no-op; `service-messaging` does not exist) | |
| 43 | + |
| 44 | +They overlap on exactly one thing: the bytes on the wire eventually hit Slack's `chat.postMessage`. Everything *around* that call is different — and that difference is the whole reason ADR-0012/0013 exist. |
| 45 | + |
| 46 | +### Why this is live now |
| 47 | + |
| 48 | +The connector baseline landed *after* ADR-0012/0013 were drafted. When 0013 was written, a Slack channel plugin was expected to hand-roll its own `fetch`, auth, base URL, and rate-limit handling ([0013 §9](./0013-bidirectional-messaging.md)). We now have a baseline that provides exactly those things generically. So the honest re-evaluation is twofold: |
| 49 | + |
| 50 | +1. **Routing question** — when a builder wants to "push to Slack," which seam do they reach for? |
| 51 | +2. **Substrate question** — now that connectors are baseline, should the (still-unbuilt) Slack channel be re-specified to *sit on top of* a connector rather than hand-roll transport? |
| 52 | + |
| 53 | +### The precedent: this is the email split, generalized |
| 54 | + |
| 55 | +ADR-0012 already drew this exact line for email: SMTP/Resend/Postmark are **transports** (`EmailTransport`), and "send a notification" is a **channel** (`MessagingChannel`) that *uses* a transport and adds preferences + outbox + suppression on top. `Connector` is simply the *general* form of "transport": a uniform way to talk to any external API. So the reconciliation is not a new idea — it is applying ADR-0012's own layering to every channel, with `Connector` as the shared transport substrate. |
| 56 | + |
| 57 | +--- |
| 58 | + |
| 59 | +## Decision |
| 60 | + |
| 61 | +### 1. Three concerns, three homes — do not collapse them |
| 62 | + |
| 63 | +``` |
| 64 | +┌────────────────────────────────────────────────────────────────────────┐ |
| 65 | +│ CONVERSATION / INBOUND ADR-0013 │ |
| 66 | +│ receiver · verify · sessions · target-resolver → flow/hook/AI tool │ |
| 67 | +└───────────────────────────────┬──────────────────────────────────────────┘ |
| 68 | + │ reuses |
| 69 | +┌───────────────────────────────▼──────────────────────────────────────────┐ |
| 70 | +│ HUMAN NOTIFICATION ADR-0012 │ |
| 71 | +│ notify node · recipient resolve · preference matrix · inbox · outbox │ |
| 72 | +│ MessagingChannel.send() │ |
| 73 | +└───────────────────────────────┬──────────────────────────────────────────┘ |
| 74 | + │ delegates transport to |
| 75 | +┌───────────────────────────────▼──────────────────────────────────────────┐ |
| 76 | +│ INTEGRATION MECHANISM ADR-0015 / 0018 (BUILT) │ |
| 77 | +│ Connector · connector_action · auth · base URL · rate-limit · actions │ |
| 78 | +└────────────────────────────────────────────────────────────────────────────┘ |
| 79 | +``` |
| 80 | + |
| 81 | +- **Integration mechanism** (`Connector`, `connector_action`): "talk to any external API." Baseline, shipped. Slack-as-an-API-target is a legitimate connector. |
| 82 | +- **Human notification** (`MessagingChannel`, `notify`): "tell a person, honoring their preferences, with durable delivery." ADR-0012. **A connector is not a substitute** for this layer. |
| 83 | +- **Conversation/inbound** (sessions, resolvers, AI): ADR-0013. Built on the notification layer. |
| 84 | + |
| 85 | +### 2. Routing rule — answer to "should push-to-Slack be a connector?" |
| 86 | + |
| 87 | +| What the builder wants | Seam | Available | |
| 88 | +|:---|:---|:---| |
| 89 | +| "Notify the deal **owner** that the deal closed" (respect their channel preference, write the inbox, retry on failure, reply in the same thread) | **`MessagingChannel` + `notify` node** | when ADR-0012 ships | |
| 90 | +| "Post **this exact text** to **#ops**" / "look up a Slack user" / "create a channel" — a raw API step with no recipient/preference semantics | **`connector_action` on a Slack `Connector`** | **today** (needs a `connector-slack` plugin) | |
| 91 | + |
| 92 | +The litmus test: **does a *person's preference* decide whether and where this message lands?** If yes → `MessagingChannel`. If the flow author has already decided the exact destination and just needs the API called → `connector_action`. |
| 93 | + |
| 94 | +### 3. One transport — the channel delegates to the connector |
| 95 | + |
| 96 | +A Slack `MessagingChannel.send()` SHOULD be implemented on top of a Slack `Connector`'s outbound action(s), so Slack auth, base URL, rate-limit handling, and the action set live in **one** place (the connector) and the channel adds only the messaging semantics (preference check, render, outbox row, session/thread decoding). This: |
| 97 | + |
| 98 | +- removes the hand-rolled `fetch` that [0013 §9](./0013-bidirectional-messaging.md) currently assumes; |
| 99 | +- gives the connector path and the channel path a **single** Slack-auth/rate-limit implementation; |
| 100 | +- lets the same Slack `Connector` serve raw `connector_action` flow steps *and* back the channel. |
| 101 | + |
| 102 | +This is the ADR-0012 "email = transport sub-system behind a channel" pattern, with `Connector` as the universal transport. |
| 103 | + |
| 104 | +### 4. Open-source / enterprise boundary (consistent with ADR-0015) |
| 105 | + |
| 106 | +| Capability | Tier | |
| 107 | +|:---|:---| |
| 108 | +| `Connector` contract + `connector_action` dispatch + in-process registry | **open** (shipped) | |
| 109 | +| Static-auth REST connector (`@objectstack/connector-rest`) | **open** (shipped) | |
| 110 | +| `MessagingChannel` interface + outbox + inbox + preference matrix + `notify` | **open baseline** — this is the #1292 P0 fix; it must be in the open framework | |
| 111 | +| A community Slack connector / channel using a **static bot token** (bearer) | **open** | |
| 112 | +| Managed OAuth2 **install + token refresh**, credential vault (`service-secrets`), multi-tenant `sys_channel_account` lifecycle, premium connectors, marketplace | **enterprise** (per ADR-0015; ADR-0013 leans on `service-secrets` + OAuth — those are the enterprise bits) | |
| 113 | + |
| 114 | +A static Slack bot token is just a bearer credential, so a basic open-source Slack connector + channel is shippable in the framework; the managed-credential / OAuth-refresh / multi-tenant lifecycle is the enterprise line — the same split ADR-0015 drew for datasources and ADR-0018's addendum drew for connectors. |
| 115 | + |
| 116 | +### 5. Anti-patterns this rules out |
| 117 | + |
| 118 | +- ❌ **A generic "notification connector."** Notification is a *semantic layer above* connectors, not a connector. Do not invent one. |
| 119 | +- ❌ **Growing preference/inbox/outbox/session logic onto `Connector`.** That re-implements ADR-0012 in the wrong layer. The connector stays a dumb, generic API caller. |
| 120 | +- ❌ **A Slack channel plugin that hand-rolls its own `fetch`/auth** when a Slack connector already encapsulates it. One transport implementation, reused. |
| 121 | +- ❌ **Forcing `plugin-email` to become a connector right now.** It predates this, has its own template/transport story, and ADR-0012 already folds it in as `plugin-notification-email`. Leave it; revisit only if a generic transport buys something concrete. |
| 122 | + |
| 123 | +--- |
| 124 | + |
| 125 | +## Consequences |
| 126 | + |
| 127 | +**Positive** |
| 128 | +- One unambiguous answer to "where does push-to-Slack live," with a one-line litmus test builders can apply. |
| 129 | +- The unbuilt messaging stack gets a free, uniform transport substrate (the connector baseline) instead of N hand-rolled HTTP clients. |
| 130 | +- The open/enterprise line stays identical across datasources, connectors, and channels — one mental model. |
| 131 | +- No migration: ADR-0012/0013 are Draft, so this is a spec revision before code exists. |
| 132 | + |
| 133 | +**Negative / costs** |
| 134 | +- ADR-0012/0013 need a small revision to say "the Slack channel's `send()` delegates to a Slack connector" (this ADR records the intent; the prose edits to 0012/0013 are follow-ups). |
| 135 | +- A Slack `Connector` and a Slack `MessagingChannel` both exist — two artifacts for one product. Mitigated by the strict layering (mechanism vs semantic) and the shared transport, so there is no duplicated Slack-auth code. |
| 136 | +- The `connector_action`-direct path can post to Slack *without* preference/inbox semantics. That is intended (it is the "raw API call" escape hatch), but docs must steer "notify a human" toward `notify`, not `connector_action`, so authors don't accidentally bypass mute/quiet-hours. |
| 137 | + |
| 138 | +--- |
| 139 | + |
| 140 | +## Status of the surrounding ADRs (so scope is clear) |
| 141 | + |
| 142 | +- **Built**: connector mechanism — `connector_action` baseline node, `engine.registerConnector()`, `@objectstack/connector-rest` (ADR-0018 addendum, merged 2026-05-31). |
| 143 | +- **Draft, unbuilt**: `service-messaging`, the `notify` transport, the preference matrix, the inbox, `plugin-messaging-slack` (ADR-0012/0013). The `notify` node is still a no-op. |
| 144 | +- **This ADR changes no code.** It records the layering decision and the routing rule so that when ADR-0012/0013 are implemented, the Slack channel is built *on* the connector substrate rather than beside it. |
| 145 | + |
| 146 | +## Follow-ups (not in this ADR) |
| 147 | + |
| 148 | +- Add a back-reference note to [ADR-0012 §2](./0012-notification-platform.md) and [ADR-0013 §9](./0013-bidirectional-messaging.md): "channel transport delegates to a `Connector` per ADR-0022." |
| 149 | +- A `connector-slack` plugin (open, static-token) — would validate the registry with a second connector and immediately enable the `connector_action`-direct path; lands independently of the messaging stack. |
| 150 | +- Revisit whether `plugin-email`'s transports should re-express as connectors once a second transport-heavy channel exists. |
0 commit comments