Requirements: read.mode: gateway — an instance that never opens Socket Mode
Introduction
An instance behind a gateway (issue-496) must set channels.slack.read.mode: socket today. That is the only value that keeps the Approve / Request changes buttons, the Execute / Start buttons and the kickoff repository picker. It is safe only because the gateway: block quietly switches off the listener that socket asks for. The config says "listen" and the runtime does not listen. If someone deletes the gateway: block, the instance opens a second Socket Mode connection on the gateway's app, and Slack splits every inbound envelope between the two. The obvious alternative, mode: off, loses the buttons and the picker while mentions keep arriving (#503).
The fix is a fourth read mode, gateway. The ticket's owner picked this option, option 1 (comment). read.mode then names the transport (poll, socket, gateway, off). Whether interactive payloads can reach the instance is derived from that transport, not from == "socket".
Requirements
Requirement 1 — a gateway read mode
User story: As an operator running an instance behind a gateway, I want to declare channels.slack.read.mode: gateway, so that my config says what the instance does: it takes Slack events from the gateway and never opens Socket Mode itself.
Acceptance criteria (EARS)
- The system SHALL accept
gatewayas a value ofchannels.slack.read.mode, besidepoll,socketandoff, in the CLI config schema and in the parsed channel config. - WHILE
read.modeisgatewaythe system SHALL NOT open a Socket Mode connection: not as a hosted ingress, not fromthe-loop start, and not fromthe-loop channels listen. - WHILE
read.modeisgatewaythe system SHALL NOT start the poll-mode reply reader. - WHILE
read.modeisgatewayand a usablegateway:block is present, the system SHALL handle the Slack envelopes the gateway client delivers exactly as it handles them insocketmode (messages, mentions, button presses, message shortcuts, view submissions, slash commands). - WHILE
read.modeisgatewaythe system SHALL allow the catch-up and reconciliation read (the-loop channels poll) exactly as it allows it insocketmode.
Requirement 2 — interactivity keys on reachability, not on socket
User story: As an operator behind a gateway, I want the Approve / Request changes buttons, the Execute / Start buttons and the kickoff picker to render, so that a gateway consumer is not a half-working instance.
Acceptance criteria (EARS)
- The system SHALL treat interactive payloads as reachable WHEN
read.modeissocket, or WHENread.modeisgatewayand a usablegateway:block is present, and otherwise SHALL treat them as unreachable. - WHEN interactive payloads are reachable THEN the system SHALL render the Approve / Request changes buttons under the
gate.feedbackgrant, the Execute / Start buttons under thecontrol.commandgrant, and the kickoff repository picker under thework-item.creategrant, by the same grant rules as today. - WHEN a kickoff picker answer is pressed THEN the system SHALL re-check the picker rule at press time against the same reachability rule (R2.1).
- IF interactive payloads are unreachable THEN the system SHALL render none of those buttons and SHALL refuse the picker exactly as it does today in
pollmode.
Requirement 3 — fail closed without a gateway
User story: As an operator who removes the gateway: block, I want the instance to stop reading Slack rather than open a socket, so that a deleted block can never put a second Socket Mode consumer on the gateway's app.
Acceptance criteria (EARS)
- IF
read.modeisgatewayand the config carries no usablegateway:block THEN the system SHALL read nothing from Slack, SHALL render no interactive element, SHALL open no Socket Mode connection, and SHALL log an error that names both keys. - IF
read.modeisgatewayand the config carries no usablegateway:block THENthe-loop channels statusSHALL say that nothing reads Slack and why. - The system SHALL open a Socket Mode connection only WHEN
read.modeis explicitlysocket.
Requirement 4 — socket beside a gateway is flagged, not silently honoured
User story: As an operator with a 20.3.0 gateway config (gateway: + mode: socket), I want to keep working after the upgrade, and I want to be told my config is out of date, so that nothing stops and the lie gets fixed.
Acceptance criteria (EARS)
- WHILE a usable
gateway:block is present andread.modeissocket, the system SHALL keep the Socket Mode listener off and keep interactivity on, as 20.3.0 does. - WHILE a usable
gateway:block is present andread.modeissocket, the system SHALL log a warning that namesread.mode: gatewayandthe-loop migrate-config.the-loop channels statusSHALL show the same warning. - WHILE a usable
gateway:block is present,the-loop channels listenSHALL refuse to open a Socket Mode connection whateverread.modesays, and SHALL name the gateway as the reason. - The system SHALL NOT refuse to load a config only because it carries a
gateway:block withread.mode: socket.
Requirement 5 — migrate-config rewrites the old form
User story: As an operator upgrading, I want the-loop migrate-config to rewrite gateway: + mode: socket to mode: gateway, so that I do not have to edit the file by hand.
Acceptance criteria (EARS)
- WHEN
the-loop migrate-configruns on a config with agateway:block andchannels.slack.read.mode: socketTHEN it SHALL setread.modetogateway, report the move, and leave every other key unchanged. - WHEN it runs on a config already migrated, or one with no
gateway:block, THEN it SHALL make no change for this rule (idempotent). - The system SHALL report such a config as needing migration (
needs_migration).
Requirement 6 — the two status commands agree
User story: As an operator, I want the-loop status and the-loop channels status to tell the same story about what reads Slack.
Acceptance criteria (EARS)
- WHEN
read.modeisgatewaywith a usablegateway:block THENthe-loop channels statusSHALL print the read mode asgateway, say that events arrive through the gateway client, and report buttons, the picker, commands, mentions and shortcuts as available under their grants, as it does forsocket. - WHEN
read.modeisgatewayTHENthe-loop statusSHALL report the Slack listener as off with a detail that names the gateway, and SHALL NOT report it as enabled but down. - WHEN
read.modeisgatewaywithout a usablegateway:block THENthe-loop statusSHALL report the Slack listener as disabled with a detail naming the missing block.
Non-functional requirements
- Behaviour preserved: a config without a
gateway:block and withpoll,socketoroffbehaves exactly as in 20.3.0. - Observability: every new refusal or warning is a log line at
warningorerrorthat names the config keys involved. No secret or token value is logged. - Documentation: the schema description, the shipped
cli-config.yamltemplate, the configuration reference and thechannelsandgatewaycapability docs describe the new mode in the same change.
Security considerations
- Actors & trust: the operator who writes the CLI config is trusted. Slack users and the payloads the gateway delivers are untrusted, as today. This change does not alter who may press a button, what a grant allows, or how a payload is authenticated.
- Trust boundaries & data: the boundary that matters is the Slack app's single Socket Mode connection. A second consumer on the same app-level token makes Slack split envelopes, so about half of the button presses, mentions and slash commands silently land on the wrong process. The new mode closes the config path that let a deleted
gateway:block create that second consumer. No token, secret or personal data is stored or moved differently. - Abuse cases (EARS):
- WHEN an operator removes the
gateway:block from amode: gatewayconfig THEN the system SHALL open no Socket Mode connection and SHALL read nothing (R3.1). - WHEN
read.modeis a typo or an unknown value THEN the system SHALL keep resolving it tooff, never to a reading mode or a socket. - WHEN
the-loop channels listenis run on an instance with agateway:block THEN the system SHALL refuse to open a socket (R4.3). - WHEN interactive payloads are unreachable THEN the system SHALL render no button, so an Approve that can never be received is never shown (decision-103 D5).
- WHEN an operator removes the
- Fail closed:
mode: gatewaywith no usablegateway:block reads nothing and renders nothing interactive. An unknown mode staysoff.
Out of scope
- Option 2 from the ticket (a separate
read.listenerswitch). The owner chose option 1. - Any change to the gateway process itself, its Slack adapters, or how it routes deliveries to instances.
- Refusing
gateway:+mode: socketat config load (R4.4 keeps it running). - A config schema version bump. The migration is detected by its own predicate, so a config that declares
0.12.0is not refused.
Open questions
None. The one design choice, option 1 versus option 2, was answered on the ticket.