Status: Proposed (2026-05-31) — routing decision recorded; both substrates now exist independently (connector-slack built; service-messaging notify/outbox/preferences built), but the §3 shared-transport wiring — a Slack MessagingChannel.send() delegating to the Slack Connector — is unbuilt (no slack-channel in service-messaging). (2026-07-16 audit)
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/*
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 ofservice-messaging, thenotifytransport, orplugin-messaging-slackis built yet. - ADR-0015/0018 + the just-merged connector baseline give us a
Connector— a generic "call any external system's API" mechanism (connector_actionnode,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:
- A
Connectoris the transport/integration mechanism — "talk to system X's API," no recipient semantics. AMessagingChannelis 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. - "Notify a human via Slack" is NOT a connector — it is a
MessagingChannel(ADR-0012/0013). Modeling it as a bareconnector_actionwould silently drop preferences, the inbox, retry/dead-letter, and the reply-in-thread story, and would duplicate ADR-0012. - "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. - The two share one transport. A Slack
MessagingChannel.send()should be implemented on top of a SlackConnector(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.
| 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.
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:
- Routing question — when a builder wants to "push to Slack," which seam do they reach for?
- 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?
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.
┌────────────────────────────────────────────────────────────────────────┐
│ 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.
| 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.
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
fetchthat 0013 §9 currently assumes; - gives the connector path and the channel path a single Slack-auth/rate-limit implementation;
- lets the same Slack
Connectorserve rawconnector_actionflow steps and back the channel.
This is the ADR-0012 "email = transport sub-system behind a channel" pattern, with Connector as the universal transport.
| 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.
- ❌ 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-emailto become a connector right now. It predates this, has its own template/transport story, and ADR-0012 already folds it in asplugin-notification-email. Leave it; revisit only if a generic transport buys something concrete.
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
Connectorand a SlackMessagingChannelboth 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" towardnotify, notconnector_action, so authors don't accidentally bypass mute/quiet-hours.
- Built: connector mechanism —
connector_actionbaseline node,engine.registerConnector(),@objectstack/connector-rest(ADR-0018 addendum, merged 2026-05-31). - Draft, unbuilt:
service-messaging, thenotifytransport, the preference matrix, the inbox,plugin-messaging-slack(ADR-0012/0013). Thenotifynode 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.
- Add a back-reference note to ADR-0012 §2 and ADR-0013 §9: "channel transport delegates to a
Connectorper ADR-0022." - A
connector-slackplugin (open, static-token) — would validate the registry with a second connector and immediately enable theconnector_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.