<!-- Written per the the-loop:writing skill. -->
Tasks: outbound Slack through the gateway, and a loud failure when it cannot
Eight tasks, all in this repository. The gateway side comes first (config, then the proxy, then the routes), because the instance's client is tested against it. The instance side runs in parallel until the wording task, which needs both. The scenarios and the docs come last. Every task is red → green: the test is written first and the commit message records the transition. No task touches the gateway's store.
Task list
- [x] 1. Gateway config: the
slack.postblockSlackPost(enabled: bool = True)onSlackIngress;_slack()parsespost.enabled;gateway-config.schema.jsongainsslack.post(additionalProperties: false, one boolean, defaulttrue, a description the onboarding walk asks from); the template carries the key with its comment; thebotTokenEnvdescription says "and for posting on the instances' behalf".restart_required()andsecret_names()unchanged.- Depends on: none
- Requirements: R6.1, R6.3, R6.4, NFR compatibility
- Test: T1 + T10 —
tests/test_gateway_slack_proxy.py -k config(a file withoutslack.postreadsenabled: true;falseparses; the schema validates both; the description is present) (red→green)
- [x] 2. Gateway proxy:
gateway/slack_proxy.pySLACK_METHODS(the fourteen names),ProxyResult,SlackProxy(state, web_client_factory=build_client):available()→(ok, reason)read at call time;call(method, params)→503 posting-unavailable/422 method-not-allowed/api_call(method, json=params)with the status, body andRetry-Afterreturned as they are, aSlackApiErrorreturned as Slack's status and body, a transport error as502 slack-unreachable;download(url)throughattachments.fetch_urlwithSLACK_HOSTSandMAX_BYTES,FetchError→422/413/502.gateway.slack_callrecord: instance, method, channel, status, ok — nevertext,blocksor the token.- Depends on: 1
- Requirements: R2.1–R2.4, R2.6, R3.1, R3.2, R6.1
- Test: T1 + T8 —
tests/test_gateway_slack_proxy.py -k "proxy or abuse"(every allowed method forwarded with a JSON body; an unknown method refused with no call on the fake web client, abuse 1; disabled / no token / no block →503, abuse 5; a fakeSlackApiErrorreturned verbatim; off-host and over-cap downloads refused before any fetch, abuse 4; the log record carries no message text) (red→green)
- [x] 3. Gateway routes, health, contract and
gateway statusSlackCallBody/SlackDownloadBodywith the bounds (NAME_MAX,method ≤ 64,params ≤ 256 KiBserialised,url ≤ 2048https);POST /api/v1/gateway/slack/calland/slack/downloadbehind_check_allowedandstore.peer_conflict, then the proxy;GatewayState.health()["slack"]["posting"];gateway statusprints posting in text and carries it in--format json.the-loop-gateway.v1.yaml: both operations (gatewaySlackCall,gatewaySlackDownload), their bodies, every error body; the description names "post as the bot" among what an exposed gateway permits.- Depends on: 2
- Requirements: R2.1, R2.5, R4.6, R5.3, R5.4, R6.2, abuse 6 (doc)
- Test: T1 + T3 + T8 —
tests/test_gateway_slack_proxy.py -k "route or abuse"(unknown instance and stale peer →409; oversizedparams,method,url→422at the door; health carriesposting;gateway statusJSON carries it) andtests/test_api_contract_parity.py(red→green)
- [x] 4. Instance proxy client:
channels/slack_gateway.pyGatewaySlackClient(url, instance, peer, transport=None)with the fourteen SDK method names,from_cli_config()(gateway block url, instance name, the peer id from<local_dir>/<PEER_FILE>or""),_callovergateway.client.urllib_transport;200 ok→dict; Slackok: false→slack_sdk.errors.SlackApiError;409/422/502/503/ unreachable →GatewayPostingError(ChannelError);fetch()withfetch_url's signature, ignoringcredential, raisingFetchError(reason).- Depends on: none (tested against a fake transport; task 3 is its live counterpart)
- Requirements: R4.2, R4.4, R3.3
- Test: T1 —
tests/test_slack_gateway_client.py(each mapping row; the peer file read; no local token read on any failure) (red→green)
- [x] 5. Instance resolver:
posting_path,outbound_client, and the call sitesPostingPath,posting_path(config, cli_config, gateway_posting=None),outbound_client(config, cli_config, client_factory=None)inchannels/slack.py;SlackChannel.__init__(…, cli_config=None)and_load_slackpasses the config;_client()→ the resolver;fetch_attachmentspassesfetch=client.fetchfor aGatewaySlackClient;slack_attachments' guard becomesnot token and fetch is None;SlackDirectory._client(),doctor._probeandcheck_declared_channelsuse the resolver, the doctor'sunverifiablereason is the posting path's.- Depends on: 4
- Requirements: R1.3, R3.3, R4.1–R4.4
- Test: T1 —
tests/test_slack_posting_path.py(the three paths; aclient_factorywins; today's error text plus the gateway hint; directory, doctor and attachments through the resolver; aGatewaySlackClientnever reads the token variable) (red→green)
- [x] 6. Saying it: start, status,
channels status,doctor slack, the cardcore/lifecycle.py:start_alllogs the warning and emitschannel.posting_unavailablefor pathnonewith Slack enabled, nothing otherwise;gateway_line(doc)suffixes fromslackPostingand the local token;GatewayClient.document()["slackPosting"]fromlast_health.channels_cmd.py: theposting:line;diagnose_cmd.py: the gateway posting line in_gateway_lines;ui/src/api/types.ts+ui/src/views/InstanceDetail.tsx: theslackPostingstate word on the Gateway card.- Depends on: 3, 5
- Requirements: R1.1, R1.2, R1.4, R1.5, R1.6, R4.6
- Test: T1 —
tests/test_slack_posting_path.py -k "start or status or doctor"andcd ui && bun run test -- InstanceDetail(the card renders the word) (red→green)
- [x] 7. Integration scenarios
tests/test_gateway_slack_proxy_integration.py: the six Gherkin scenarios of the testing plan throughGatewayHarness, a realSlackBotChannelon the bus and a fake Slack web client on the gateway; each docstring carriesRequirement:links. Run also with--dispatch-lag=0.5.- Depends on: 3, 5, 6
- Requirements: R1, R2, R3, R4.1, R4.2, R4.4, R4.7
- Test: T2 + T8 —
tests/test_gateway_slack_proxy_integration.py(red on the branch before tasks 3–6, green after) (red→green)
- [x] 8. Docs, decision and the documentation record
docs/capabilities/gateway.md(behaviour, "what it does not do", history row),docs/capabilities/channels.md(the outbound path, the doctor line),docs/guide/gateway.md(step 1),docs/config/cli/gateway-options.md,docs/config/cli/channels-options.md(botTokenEnvoptional behind a posting gateway),docs/cli/commands/gateway.md(statusoutput),commands/init-cli.md,commands/init-gateway.md,skills/the-loop/reference/onboarding.md.docs/decisions/decision-151.md(the gateway posts with the token it already held; amends decision-146's "any posting token" line) and its row indecisions.md.evidence/documentation.mdwith## Capability docsand## Documentation.- Depends on: 7
- Requirements: R7.1–R7.3
- Test: T12 —
uv run pre-commit run --all-files(markdownlint over every touched page; the config validation hook over the template and schema)
Dependency graph (DAG)
Two chains run independently until task 6: 1 → 2 → 3 on the gateway and 4 → 5 on the instance. Task 6 joins them, task 7 proves the whole, task 8 writes it down.
Checkpoints
- After tasks 3 and 5:
cd cli && uv run python -m pytest -q tests/test_gateway_slack_proxy.py tests/test_slack_gateway_client.py tests/test_slack_posting_path.py tests/test_api_contract_parity.py. - After task 7: the T2 command at lag 0 and at
--dispatch-lag=0.5. - After task 8:
uv run pre-commit run --all-files, then theverificationnode runstesting-plan.md(T1–T3, T8, T10, T11 on real processes with no token anywhere, T12) and records each activity; only then do the self, critic and security reviews run.
Each task's commit records its test command and the red→green transition in its message; no progress log is written (issue-365).
Review comments
Appended by the-loop's
record-feedbackhook when a human gate approves with comments (issue-109). Append-only and attributed.