Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/adr/0012-notification-platform.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -180,6 +180,8 @@ Every layer has a stable seam. A custom channel (e.g. `plugin-notification-feish

### 2. The `MessagingChannel` interface

> **Transport delegation (ADR-0022).** Per [ADR-0022](./0022-connectors-vs-messaging-channels.md), a channel's *transport* — provider auth, base URL, rate-limit handling, the provider's action set — should be implemented on top of a `Connector` (ADR-0015/0018), not hand-rolled per channel. `MessagingChannel` adds only the messaging *semantics* (preferences, inbox, outbox, sessions) on that substrate. The interface below is unchanged; this is an implementation guideline for concrete channels.

```ts
// packages/spec/src/notification/channel.zod.ts
export interface MessagingChannel {
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0013-bidirectional-messaging.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -483,6 +483,8 @@ The synthetic-user fallback is what keeps the system honest: nothing happens "as

### 9. Slack channel — first reference implementation

> **Transport delegation (ADR-0022).** The Slack channel's outbound `send()` (and any Web-API calls below) should delegate to a Slack `Connector` (e.g. `@objectstack/connector-slack`) for auth / base URL / rate-limit / the `chat.*` action set, rather than hand-rolling `fetch`. The channel adds the messaging semantics on top. See [ADR-0022](./0022-connectors-vs-messaging-channels.md) — this lets the `connector_action`-direct path and the channel share one Slack transport.

`@objectstack/plugin-messaging-slack`. Package layout:

```
Expand Down
156 changes: 133 additions & 23 deletions docs/adr/0021-analytics-dataset-semantic-layer.md

Large diffs are not rendered by default.

150 changes: 150 additions & 0 deletions docs/adr/0022-connectors-vs-messaging-channels.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
# ADR-0022: Connectors vs Messaging Channels — Where "Push to Slack" Lives

**Status**: Proposed (2026-05-31)
**Deciders**: ObjectStack Protocol Architects
**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)
**Consumers**: `@objectstack/spec` (`integration/`, future `messaging/`), `@objectstack/services/service-automation` (connector registry), future `@objectstack/service-messaging`, future `@objectstack/plugin-messaging-slack`, `@objectstack/connectors/*`

---

## TL;DR

The question "should pushing a message to Slack be a **connector**?" surfaces a real collision in the repo: **Slack now has two candidate homes.**

- **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.*
- **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.*

This ADR resolves the collision instead of letting two seams quietly grow a second Slack integration. The decision:

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.
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**.
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.
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.

Because ADR-0012/0013 are still Draft, folding the connector baseline in underneath them is a **cheap revision, not a migration**.

---

## Context

### The two seams, side by side

| Dimension | `Connector` (ADR-0015/0018, **built**) | `MessagingChannel` (ADR-0012/0013, **draft, unbuilt**) |
|:---|:---|:---|
| Purpose | call any external system's API | deliver to / converse with a **human** |
| Primitive | an **action** (a verb on a system) | a **channel** (a delivery medium for a person) |
| Recipient model | none — caller supplies explicit inputs | per-user preference matrix, mute, quiet hours, recipient resolver |
| Delivery guarantee | one call → success/fail | outbox, retry, dead-letter, cluster-lock |
| Threads / sessions | none | `sessionKey`, reply-to-thread, `sys_message_mirror` |
| Inbound | generic webhook trigger | verify+dedup+parse → `target-resolver` → flow / hook / AI tool |
| Multi-account | not modeled | `sys_channel_account`, first-class, multi-tenant |
| Identity mapping | none | `sys_channel_user_link` (Slack user ↔ `sys_user`) |
| Status today | **shipped** (`connector_action`, `@objectstack/connector-rest`) | **Draft** (`notify` is a no-op; `service-messaging` does not exist) |

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.

### Why this is live now

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:

1. **Routing question** — when a builder wants to "push to Slack," which seam do they reach for?
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?

### The precedent: this is the email split, generalized

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.

---

## Decision

### 1. Three concerns, three homes — do not collapse them

```
┌────────────────────────────────────────────────────────────────────────┐
│ CONVERSATION / INBOUND ADR-0013 │
│ receiver · verify · sessions · target-resolver → flow/hook/AI tool │
└───────────────────────────────┬──────────────────────────────────────────┘
│ reuses
┌───────────────────────────────▼──────────────────────────────────────────┐
│ HUMAN NOTIFICATION ADR-0012 │
│ notify node · recipient resolve · preference matrix · inbox · outbox │
│ MessagingChannel.send() │
└───────────────────────────────┬──────────────────────────────────────────┘
│ delegates transport to
┌───────────────────────────────▼──────────────────────────────────────────┐
│ INTEGRATION MECHANISM ADR-0015 / 0018 (BUILT) │
│ Connector · connector_action · auth · base URL · rate-limit · actions │
└────────────────────────────────────────────────────────────────────────────┘
```

- **Integration mechanism** (`Connector`, `connector_action`): "talk to any external API." Baseline, shipped. Slack-as-an-API-target is a legitimate connector.
- **Human notification** (`MessagingChannel`, `notify`): "tell a person, honoring their preferences, with durable delivery." ADR-0012. **A connector is not a substitute** for this layer.
- **Conversation/inbound** (sessions, resolvers, AI): ADR-0013. Built on the notification layer.

### 2. Routing rule — answer to "should push-to-Slack be a connector?"

| What the builder wants | Seam | Available |
|:---|:---|:---|
| "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 |
| "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) |

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`.

### 3. One transport — the channel delegates to the connector

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:

- removes the hand-rolled `fetch` that [0013 §9](./0013-bidirectional-messaging.md) currently assumes;
- gives the connector path and the channel path a **single** Slack-auth/rate-limit implementation;
- lets the same Slack `Connector` serve raw `connector_action` flow steps *and* back the channel.

This is the ADR-0012 "email = transport sub-system behind a channel" pattern, with `Connector` as the universal transport.

### 4. Open-source / enterprise boundary (consistent with ADR-0015)

| Capability | Tier |
|:---|:---|
| `Connector` contract + `connector_action` dispatch + in-process registry | **open** (shipped) |
| Static-auth REST connector (`@objectstack/connector-rest`) | **open** (shipped) |
| `MessagingChannel` interface + outbox + inbox + preference matrix + `notify` | **open baseline** — this is the #1292 P0 fix; it must be in the open framework |
| A community Slack connector / channel using a **static bot token** (bearer) | **open** |
| 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) |

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.

### 5. Anti-patterns this rules out

- ❌ **A generic "notification connector."** Notification is a *semantic layer above* connectors, not a connector. Do not invent one.
- ❌ **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.
- ❌ **A Slack channel plugin that hand-rolls its own `fetch`/auth** when a Slack connector already encapsulates it. One transport implementation, reused.
- ❌ **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.

---

## Consequences

**Positive**
- One unambiguous answer to "where does push-to-Slack live," with a one-line litmus test builders can apply.
- The unbuilt messaging stack gets a free, uniform transport substrate (the connector baseline) instead of N hand-rolled HTTP clients.
- The open/enterprise line stays identical across datasources, connectors, and channels — one mental model.
- No migration: ADR-0012/0013 are Draft, so this is a spec revision before code exists.

**Negative / costs**
- 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).
- 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.
- 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.

---

## Status of the surrounding ADRs (so scope is clear)

- **Built**: connector mechanism — `connector_action` baseline node, `engine.registerConnector()`, `@objectstack/connector-rest` (ADR-0018 addendum, merged 2026-05-31).
- **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.
- **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.

## Follow-ups (not in this ADR)

- 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."
- 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.
- Revisit whether `plugin-email`'s transports should re-express as connectors once a second transport-heavy channel exists.
Loading
Loading