Skip to content

Latest commit

 

History

History
150 lines (106 loc) · 13.8 KB

File metadata and controls

150 lines (106 loc) · 13.8 KB

ADR-0022: Connectors vs Messaging Channels — Where "Push to Slack" Lives

Status: Proposed (2026-05-31) Deciders: ObjectStack Protocol Architects Builds on: ADR-0012 (Messaging Platform — outbound notify on a generalized outbox), ADR-0013 (Bidirectional Messaging — Slack first), ADR-0015 (open mechanism / enterprise lifecycle split), ADR-0018 (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 actionconnector_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). 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 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 and ADR-0013 §9: "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.