Design: read.mode: gateway
Overview
gateway becomes a fourth value of channels.slack.read.mode. One new property, SlackChannelConfig.push_reachable, replaces the three read_mode == "socket" tests that gate interactivity. It is true for socket, and for gateway when the gateway block exists. A gateway mode with no usable gateway: block is resolved to off at parse time. That is the same fail-closed move an unknown value already gets, so every downstream reader stays correct without learning about the new case. A socket mode beside a gateway: block keeps working as in 20.3.0. It is flagged in the log and in channels status, and migrate-config rewrites it. Nothing outside the Slack channel config, its status lines, the listener entry point and the migration changes.
Architecture
Which process reads Slack, per config:
gateway: block | read.mode | Socket Mode opened here | Poll reader | Interactivity | Notes |
|---|---|---|---|---|---|
| absent | poll / socket / off | as 20.3.0 | as 20.3.0 | as 20.3.0 | unchanged |
| absent | gateway | no | no | off | resolved to off, error logged (R3.1) |
| present | gateway | no | no | on (by grant) | the new form (R1, R2) |
| present | socket | no (suppressed, as 20.3.0) | no | on (by grant) | warning; needs migration (R4) |
| present | poll | no | no (as 20.3.0) | off | unchanged |
| present | off | no | no | off | unchanged |
Components & interfaces
channels/slack.py
READ_MODES = ("poll", "socket", "gateway", "off").SlackChannelConfiggainsgateway: bool = False. It is set fromcli_config.gateway_block(config) is not Noneinfrom_mapping. The full CLI config is already its input, so no caller changes.In
from_mapping, after the unknown-value check: IFmode == "gateway"and there is no usable block, THEN log an error (channels.slack.read.mode is 'gateway' but no usable gateway: block is configured — nothing is read from Slack (fail closed); add gateway.url or set read.mode) and setmode = "off".New property:
python@property def push_reachable(self) -> bool: """Whether an interactive payload can reach this instance (issue-503): over its own Socket Mode connection, or delivered by the gateway client.""" return self.read_mode == "socket" or (self.read_mode == "gateway" and self.gateway)Because of the parse-time resolution,
self.gatewayis always true whenread_modeisgateway. The conjunct stays anyway, so a hand-built config cannot get it wrong.interactive,command_buttonsandkickoff_pickerchangeself.read_mode == "socket"toself.push_reachable. Their grant halves are unchanged.run_socket_listenerrefuses, before it reads any token, in two cases:- when
read_mode == "gateway": "this instance consumes Slack from the gateway; the gateway holds the app's Socket Mode connection"; - when
config.gatewayis true whatever the mode: the same reason, naming thegateway:block (R4.3).
Both log at
errorand return1, like the existing refusals.- when
channels/inbound.py
- No change in
poll_once: it refuses onlyoff, sogatewayis allowed as a reconciliation, exactly likesocket(R1.5). The kickoff press-time re-check already callsconfig.kickoff_picker, so it picks up R2.3 for free. Only the docstring is updated.
channels/watcher.py
- No code change.
read_mode != "poll"already returnsNoneforgateway(R1.3).
core/lifecycle.py
_slack_listener_enabledreads the raw mode and compares it withsocket, sogatewayis already "not enabled". No change.- The status rows (R6.2, R6.3):
- with a
gateway:block the existingGATEWAY_OFFrow (off · gateway configured) stays, for every mode; - with no block and a raw mode of
gateway, thedisabledrow's detail becomesread.mode is gateway but no gateway block is configured — nothing reads Slack, instead of the genericread.mode is not socket.
- with a
- New helper
socket_beside_gateway(config) -> bool: a usable block and a raw mode ofsocket. The migration predicate and the warnings below share it.
api/ingress.py
_start_gateway_clientlogs one warning whensocket_beside_gateway(cli_config)holds:channels.slack.read.mode is 'socket' beside a gateway block — the listener stays off; set read.mode: gateway (the-loop migrate-config does it). The starter runs once at boot and again only when the hosted set changes, so the warning is not repeated every few seconds (R4.2).
commands/channels_cmd.py (channels status)
read:line: forgateway, printgateway — events arrive through the gateway client (<url>); no Socket Mode connection here.- When the raw mode is
gatewayand no block exists, print[!] read.mode is gateway but no gateway block is configured — nothing reads Slack(R3.2). The parsed mode isoff, so the existingofftext follows. - When
socket_beside_gateway, print[!] read.mode is socket beside a gateway block — the listener stays off; run the-loop migrate-config to set read.mode: gateway(R4.2). commands:,mentions:,shortcuts:and the kickoff "asks which repository" line key onslack.push_reachablerather thanread_mode == "socket". The transport phrase becomesover Socket Modeorthrough the gateway, depending on the mode._button_lines: ingatewaymode the app-level-token step is not printed, because the gateway holds that token. Theset read.modestep namessocket(orgatewaywhen a block exists) only whilepush_reachableis false._split_check_lines: unchanged. The check belongs to a listener that this instance does not run ingatewaymode, anddiagnose's gateway section already reports the gateway's socket.
migrations.py
needs_migrationreturns true whensocket_beside_gateway(config)holds (R5.3).- New
_migrate_gateway_read_mode(data, report), called frommigrate_cli_configafter_migrate_slack_channel: IF a usablegatewaymapping with a non-emptyurlexists andchannels.slack.read.mode == "socket", THEN set it togateway, appendchannels.slack.read.mode socket → gateway (a gateway consumer opens no Socket Mode connection, issue-503)toreport.moves, and setchanged. Otherwise do nothing. It is idempotent by construction (R5.1, R5.2). assert_currentis unchanged, so the combination is never refused (R4.4).CURRENT_CONFIG_VERSIONis not bumped.
Schema, template, docs
cli-config.schema.json: addgatewayto theread.modeenum and rewrite its description. Touch thegatewayblock's description, which tells operators what to set beside it, and anypublish/grant description that says "needsread.mode: socket", so they read "socket or gateway".skills/the-loop/templates/cli-config.yamlandtemplates/gateway-config.yaml(wherever an instance-side example sits):poll | socket | gateway | off.docs/config/cli/channels-options.md(slack.read.mode),docs/config/cli/gateway-options.md, and the capability docschannels.mdandgateway.md, each with a history row.channels/slack-app-manifest.yaml: the one comment that saysread.mode: socket.
UI/UX design
No user-facing visual surface. The only output is CLI text (channels status, log lines), specified above.
Data models
There is no new state file and no new config key. The only addition is the enum value gateway on an existing key, plus the in-memory SlackChannelConfig.gateway flag.
Error handling
gatewaywith no usable block: error log, resolved tooff, nothing read (fail closed).channels listenwith a block present, or ingatewaymode: error log, exit1, no socket.socketbeside a block: warning, behaviour preserved.- Unknown mode: unchanged (
off, warning).
Security design
| Requirement-phase boundary / abuse case | Enforcement |
|---|---|
A deleted gateway: block must not open a second Socket Mode consumer (AC1, R3) | from_mapping resolves gateway without a block to off. enabled_services opens a listener only on a raw socket |
| A typo never reaches a reading mode (AC2) | The existing unknown → off rule runs first and is unchanged |
channels listen beside a gateway (AC3, R4.3) | run_socket_listener refuses on config.gateway before it reads any token |
| No unreceivable button (AC4, decision-103 D5) | All three gates go through push_reachable, which is false whenever nothing delivers payloads |
Authorization is untouched. Grants, routing.authorizedUsers, the ledger record and the gateway's own authentication of deliveries all run as before. Log lines name config keys and the gateway URL, never a token value. The URL is already printed by status and diagnose.
Testing strategy
- Unit tests for each parsing case, for
push_reachableand the three gates, for the listener refusals, for the migration (rewrite, idempotence, no block,needs_migration) and for thechannels statuslines. - The existing Slack, gateway, lifecycle and migration test suites must stay green. That is the "behaviour preserved" check.
- An integration test (Gherkin docstring) drives a
block_actionsenvelope through the gateway client's dispatch path withmode: gateway, and asserts the approval is handled and the rendered notification carries buttons.
The detail is in testing-plan.md.
Trade-offs & decisions
- Resolve to
offat parse time, or carry a "degraded gateway" state. Resolving at parse time keeps the dozen readers ofread_modecorrect without each of them learning a new case, and it matches the existing unknown-value rule. The cost is thatchannels statusmust read the raw config to explain why. That is one extra check in one command. - Warn on
socketbeside a block, or refuse it. Refusing would stop every 20.3.0 gateway instance on upgrade, and the loop's migration rule is "migrate broadly, refuse narrowly". The ticket allowed either ("warns or refuses"). - No schema version bump. A bump would make
assert_currentrefuse every config that declares0.12.0, which stops daemons for a cosmetic rewrite. The migration has its own predicate instead, as several earlier ones do. channels listenrefuses beside any gateway block. This is slightly broader than the ticket asked. It closes the one remaining manual path to a second socket.
Open questions
None.